diff --git a/public/app.js b/public/app.js index 965efa2..3a4e769 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,32 @@ -// Get references to the page elements we need to update. -const form = document.getElementById("uptime-form"); +// --------------------------------------------------------- +// Tool tab navigation +// --------------------------------------------------------- + +const toolTabs = document.querySelectorAll(".tool-tab"); +const toolPanels = document.querySelectorAll(".tool-panel"); + +toolTabs.forEach((tab) => { + tab.addEventListener("click", () => { + const selectedToolId = tab.dataset.tool; + + // Update the active tab. + toolTabs.forEach((currentTab) => { + currentTab.classList.toggle("active", currentTab === tab); + }); + + // Show only the selected tool panel. + toolPanels.forEach((panel) => { + panel.hidden = panel.id !== selectedToolId; + }); + }); +}); + + +// --------------------------------------------------------- +// Website Status Checker +// --------------------------------------------------------- + +const uptimeForm = document.getElementById("uptime-form"); const urlInput = document.getElementById("url-input"); const checkButton = document.getElementById("check-button"); const statusMessage = document.getElementById("status-message"); @@ -12,14 +39,11 @@ const redirectCount = document.getElementById("redirect-count"); const checkedAt = document.getElementById("checked-at"); const finalUrl = document.getElementById("final-url"); -// Listen for the form submission. -form.addEventListener("submit", async (event) => { - // Prevent the browser from reloading the page. +uptimeForm.addEventListener("submit", async (event) => { event.preventDefault(); const url = urlInput.value.trim(); - // Reset the interface before starting a new check. statusMessage.textContent = "Checking website..."; statusMessage.className = "status-message"; resultPanel.hidden = true; @@ -37,17 +61,14 @@ form.addEventListener("submit", async (event) => { const result = await response.json(); - // Handle API-level errors. if (!response.ok) { throw new Error(result.error || "The request failed."); } - // Handle unreachable or blocked destinations. if (!result.online) { throw new Error(result.error || "The website could not be reached."); } - // Fill in the successful result. onlineBadge.textContent = "Online"; onlineBadge.className = "status-badge online"; @@ -62,15 +83,119 @@ form.addEventListener("submit", async (event) => { statusMessage.textContent = "Website check completed."; resultPanel.hidden = false; } catch (error) { - // Show a safe, readable message to the user. statusMessage.textContent = error.message; statusMessage.className = "status-message error"; - - onlineBadge.textContent = "Offline"; - onlineBadge.className = "status-badge offline"; } finally { - // Restore the button whether the request succeeded or failed. checkButton.disabled = false; checkButton.textContent = "Check Website"; } -}); \ No newline at end of file +}); + + +// --------------------------------------------------------- +// DNS Lookup +// --------------------------------------------------------- + +const dnsForm = document.getElementById("dns-form"); +const hostnameInput = document.getElementById("hostname-input"); +const dnsButton = document.getElementById("dns-button"); +const dnsMessage = document.getElementById("dns-message"); +const dnsResultPanel = document.getElementById("dns-result-panel"); +const dnsHostname = document.getElementById("dns-hostname"); +const dnsRecords = document.getElementById("dns-records"); +const dnsCheckedAt = document.getElementById("dns-checked-at"); + +// Convert one DNS record into readable text. +function formatDnsRecord(recordType, record) { + if (recordType === "MX" && typeof record === "object") { + return `${record.exchange} (priority ${record.priority})`; + } + + return String(record); +} + +// Build one visible record group. +function createDnsRecordGroup(recordType, records) { + const group = document.createElement("section"); + group.className = "dns-record-group"; + + const heading = document.createElement("h4"); + heading.textContent = recordType; + group.appendChild(heading); + + if (!Array.isArray(records) || records.length === 0) { + const emptyMessage = document.createElement("p"); + emptyMessage.className = "empty-record"; + emptyMessage.textContent = "No records found."; + group.appendChild(emptyMessage); + + return group; + } + + const list = document.createElement("ul"); + list.className = "dns-record-list"; + + records.forEach((record) => { + const item = document.createElement("li"); + item.textContent = formatDnsRecord(recordType, record); + list.appendChild(item); + }); + + group.appendChild(list); + + return group; +} + +dnsForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const hostname = hostnameInput.value.trim(); + + dnsMessage.textContent = "Looking up DNS records..."; + dnsMessage.className = "status-message"; + dnsResultPanel.hidden = true; + dnsButton.disabled = true; + dnsButton.textContent = "Looking Up..."; + + try { + const response = await fetch("/api/dns", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ hostname }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || "The DNS lookup failed."); + } + + dnsHostname.textContent = result.hostname; + dnsRecords.replaceChildren(); + + const recordOrder = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]; + + recordOrder.forEach((recordType) => { + const group = createDnsRecordGroup( + recordType, + result.records[recordType] + ); + + dnsRecords.appendChild(group); + }); + + dnsCheckedAt.textContent = + `Checked ${new Date(result.checkedAt).toLocaleString()}`; + + dnsMessage.textContent = "DNS lookup completed."; + dnsResultPanel.hidden = false; + } catch (error) { + dnsMessage.textContent = error.message; + dnsMessage.className = "status-message error"; + } finally { + dnsButton.disabled = false; + dnsButton.textContent = "Look Up DNS"; + } +}); diff --git a/public/index.html b/public/index.html index b627f63..8f68fa3 100644 --- a/public/index.html +++ b/public/index.html @@ -3,27 +3,55 @@ - + ByteGeist Toolbox - +
-
+ + + + + +
-

BYTEGEIST TOOLBOX

-

Website Status Checker

-

- Check whether a public website is responding right now and view its status code, - response time, redirects, and final destination. +

Website Status Checker

+

+ Check whether a public website is responding right now and view its + status code, response time, redirects, and final destination.

-
+
@@ -42,13 +70,11 @@
-

-
+ + +
- + - \ No newline at end of file + diff --git a/public/styles.css b/public/styles.css index 4f59596..e5e42db 100644 --- a/public/styles.css +++ b/public/styles.css @@ -233,4 +233,112 @@ button:disabled { button { width: 100%; } -} \ No newline at end of file +} +.site-header { + width: min(760px, 100%); + margin-bottom: 18px; +} + +.site-header h1 { + margin-bottom: 10px; +} + +.tool-tabs { + display: flex; + width: min(760px, 100%); + gap: 8px; + margin-bottom: 14px; +} + +.tool-tab { + padding: 10px 14px; + border: 1px solid var(--border); + background: #0d1523; + color: var(--muted); +} + +.tool-tab:hover { + background: var(--surface-light); + color: var(--text); +} + +.tool-tab.active { + border-color: var(--accent); + background: rgba(76, 166, 255, 0.12); + color: var(--accent); +} + +.tool-header h2 { + margin-bottom: 8px; + font-size: 1.6rem; +} + +.tool-description { + margin-bottom: 24px; + color: var(--muted); + line-height: 1.6; +} + +.hostname-badge { + padding: 5px 10px; + border-radius: 999px; + background: rgba(76, 166, 255, 0.12); + color: var(--accent); + font-size: 0.82rem; + font-weight: 700; + overflow-wrap: anywhere; +} + +.dns-records { + display: grid; + gap: 12px; +} + +.dns-record-group { + border: 1px solid var(--border); + border-radius: 8px; + background: #0d1523; + overflow: hidden; +} + +.dns-record-group h4 { + margin: 0; + padding: 11px 14px; + border-bottom: 1px solid var(--border); + color: var(--accent); + font-size: 0.9rem; + letter-spacing: 0.08em; +} + +.dns-record-list { + margin: 0; + padding: 12px 14px 12px 34px; +} + +.dns-record-list li { + margin: 5px 0; + overflow-wrap: anywhere; +} + +.empty-record { + margin: 0; + padding: 12px 14px; + color: var(--muted); +} + +.dns-checked-at { + margin: 14px 0 0; + color: var(--muted); + font-size: 0.84rem; +} + +@media (max-width: 640px) { + .tool-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .tool-tab { + width: 100%; + } +} diff --git a/src/middleware/validateHostname.js b/src/middleware/validateHostname.js new file mode 100644 index 0000000..bd7e624 --- /dev/null +++ b/src/middleware/validateHostname.js @@ -0,0 +1,58 @@ +// Maximum length allowed by the DNS hostname standard. +const MAX_HOSTNAME_LENGTH = 253; + +// Validate and normalize a submitted hostname. +function validateHostname(value) { + // Make sure the submitted value is text and is not empty. + if (typeof value !== "string" || value.trim() === "") { + throw new Error("A hostname is required."); + } + + // Remove surrounding spaces and normalize uppercase letters. + let hostname = value.trim().toLowerCase(); + + // Allow users to paste a complete website URL. + // Example: https://example.com/page becomes example.com. + if (hostname.startsWith("http://") || hostname.startsWith("https://")) { + try { + hostname = new URL(hostname).hostname.toLowerCase(); + } catch { + throw new Error("Enter a valid hostname or website URL."); + } + } + + // Remove a trailing period from a fully qualified hostname. + // Example: example.com. becomes example.com. + if (hostname.endsWith(".")) { + hostname = hostname.slice(0, -1); + } + + // DNS hostnames cannot exceed 253 characters. + if (hostname.length > MAX_HOSTNAME_LENGTH) { + throw new Error("The hostname is too long."); + } + + // Require normal DNS labels separated by periods. + // Letters, numbers, and interior hyphens are allowed. + const hostnamePattern = + /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i; + + if (!hostnamePattern.test(hostname)) { + throw new Error("Enter a valid public hostname, such as example.com."); + } + + // Block local-only hostnames. + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") + ) { + throw new Error("Local hostnames are not allowed."); + } + + return hostname; +} + +module.exports = { + validateHostname, +}; \ No newline at end of file diff --git a/src/routes/dns.js b/src/routes/dns.js new file mode 100644 index 0000000..ae5fb3f --- /dev/null +++ b/src/routes/dns.js @@ -0,0 +1,26 @@ +const express = require("express"); + +const { lookupDns } = require("../services/dnsService"); +const { validateHostname } = require("../middleware/validateHostname"); +const { uptimeRateLimiter } = require("../middleware/rateLimit"); + +const router = express.Router(); + +// POST /api/dns +router.post("/", uptimeRateLimiter, async (req, res) => { + try { + // Validate and normalize the submitted hostname. + const hostname = validateHostname(req.body.hostname); + + // Perform the DNS lookup. + const result = await lookupDns(hostname); + + return res.json(result); + } catch (error) { + return res.status(400).json({ + error: error.message || "The DNS lookup failed.", + }); + } +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 00bb9a2..75a9030 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,9 @@ const path = require("path"); // Import the uptime API route. const uptimeRouter = require("./routes/uptime"); +// Import the DNS lookup API route. +const dnsRouter = require("./routes/dns"); + // Create the Express application. const app = express(); @@ -23,6 +26,9 @@ app.use(express.static(path.join(__dirname, "..", "public"))); // Handle website uptime checks. app.use("/api/uptime", uptimeRouter); +// Handle DNS lookup requests. +app.use("/api/dns", dnsRouter); + // Simple health-check route. app.get("/health", (req, res) => { res.json({ diff --git a/src/services/dnsService.js b/src/services/dnsService.js new file mode 100644 index 0000000..89d9e8c --- /dev/null +++ b/src/services/dnsService.js @@ -0,0 +1,65 @@ +// Import Node's built-in DNS promise API. +const dns = require("dns").promises; + +// Record types supported by the first DNS Lookup version. +const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS"]; + +// Errors that usually mean a record type simply does not exist. +const EMPTY_RECORD_ERRORS = new Set([ + "ENODATA", + "ENOTFOUND", + "ENOENT", + "ENODOMAIN", +]); + +// Look up one DNS record type without crashing the entire request. +async function resolveRecord(hostname, recordType) { + try { + const records = await dns.resolve(hostname, recordType); + + // TXT records arrive as arrays of text fragments. + // Join each record into one readable string. + if (recordType === "TXT") { + return records.map((parts) => parts.join("")); + } + + return records; + } catch (error) { + // A domain may have an A record but no MX, TXT, or CNAME record. + // That is normal, so return an empty array. + if (EMPTY_RECORD_ERRORS.has(error.code)) { + return []; + } + + throw error; + } +} + +// Look up all supported DNS record types. +async function lookupDns(hostname) { + const results = {}; + + // Run all record lookups at the same time. + const lookups = RECORD_TYPES.map(async (recordType) => { + try { + results[recordType] = await resolveRecord(hostname, recordType); + } catch { + // One failed record type should not erase successful results. + results[recordType] = []; + } + }); + + await Promise.all(lookups); + + return { + hostname, + records: results, + checkedAt: new Date().toISOString(), + }; +} + +module.exports = { + lookupDns, + resolveRecord, + RECORD_TYPES, +}; diff --git a/tests/dnsService.test.js b/tests/dnsService.test.js new file mode 100644 index 0000000..72a01e0 --- /dev/null +++ b/tests/dnsService.test.js @@ -0,0 +1,65 @@ +// Import Node's built-in test tools. +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Import the DNS service. +const { + lookupDns, + resolveRecord, + RECORD_TYPES, +} = require("../src/services/dnsService"); + +test("supports the expected DNS record types", () => { + assert.deepEqual( + RECORD_TYPES, + ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + ); +}); + +test("resolves A records for example.com", async () => { + const records = await resolveRecord("example.com", "A"); + + assert.equal(Array.isArray(records), true); + assert.equal(records.length > 0, true); +}); + +test("returns an array for a missing optional record type", async () => { + const records = await resolveRecord( + "example.com", + "CNAME" + ); + + assert.equal(Array.isArray(records), true); +}); + +test("joins TXT record fragments into strings", async () => { + const records = await resolveRecord("google.com", "TXT"); + + assert.equal(Array.isArray(records), true); + + records.forEach((record) => { + assert.equal(typeof record, "string"); + }); +}); + +test("returns all supported record groups", async () => { + const result = await lookupDns("example.com"); + + assert.equal(result.hostname, "example.com"); + assert.equal(typeof result.checkedAt, "string"); + assert.equal(typeof result.records, "object"); + + RECORD_TYPES.forEach((recordType) => { + assert.equal( + Array.isArray(result.records[recordType]), + true + ); + }); +}); + +test("returns a valid ISO timestamp", async () => { + const result = await lookupDns("example.com"); + const timestamp = Date.parse(result.checkedAt); + + assert.equal(Number.isNaN(timestamp), false); +}); diff --git a/tests/validateHostname.test.js b/tests/validateHostname.test.js new file mode 100644 index 0000000..9c4fb66 --- /dev/null +++ b/tests/validateHostname.test.js @@ -0,0 +1,115 @@ +// Import Node's built-in test tools. +const test = require("node:test"); +const assert = require("node:assert/strict"); + +// Import the function we want to test. +const { + validateHostname, +} = require("../src/middleware/validateHostname"); + +test("accepts a normal hostname", () => { + assert.equal(validateHostname("example.com"), "example.com"); +}); + +test("normalizes uppercase letters", () => { + assert.equal(validateHostname("EXAMPLE.COM"), "example.com"); +}); + +test("trims surrounding spaces", () => { + assert.equal(validateHostname(" example.com "), "example.com"); +}); + +test("extracts the hostname from an HTTPS URL", () => { + assert.equal( + validateHostname("https://example.com/some/page"), + "example.com" + ); +}); + +test("extracts the hostname from an HTTP URL", () => { + assert.equal( + validateHostname("http://sub.example.com/path"), + "sub.example.com" + ); +}); + +test("removes a trailing period", () => { + assert.equal(validateHostname("example.com."), "example.com"); +}); + +test("accepts subdomains", () => { + assert.equal( + validateHostname("api.tools.example.com"), + "api.tools.example.com" + ); +}); + +test("rejects an empty hostname", () => { + assert.throws( + () => validateHostname(""), + /A hostname is required/ + ); +}); + +test("rejects whitespace-only input", () => { + assert.throws( + () => validateHostname(" "), + /A hostname is required/ + ); +}); + +test("rejects non-string input", () => { + assert.throws( + () => validateHostname(null), + /A hostname is required/ + ); +}); + +test("rejects malformed hostnames", () => { + assert.throws( + () => validateHostname("not a real hostname"), + /Enter a valid public hostname/ + ); +}); + +test("rejects hostnames beginning with a hyphen", () => { + assert.throws( + () => validateHostname("-example.com"), + /Enter a valid public hostname/ + ); +}); + +test("rejects hostnames ending with a hyphen", () => { + assert.throws( + () => validateHostname("example-.com"), + /Enter a valid public hostname/ + ); +}); + +test("rejects localhost", () => { + assert.throws( + () => validateHostname("localhost"), + /Enter a valid public hostname/ + ); +}); + +test("rejects localhost subdomains", () => { + assert.throws( + () => validateHostname("test.localhost"), + /Local hostnames are not allowed/ + ); +}); + +test("rejects .local hostnames", () => { + assert.throws( + () => validateHostname("printer.local"), + /Local hostnames are not allowed/ + ); +}); + +test("rejects an invalid URL", () => { + assert.throws( + () => validateHostname("https://"), + /Enter a valid hostname or website URL/ + ); +});