From 9d136782d3f86675edf23c6b96b99a814a9cc2c9 Mon Sep 17 00:00:00 2001 From: lean <1337lean@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:32:49 -0400 Subject: [PATCH] Harden and polish buffer.lol --- .dockerignore | 8 + .env.example | 7 +- .github/workflows/ci.yml | 20 + .github/workflows/deploy.yml | 16 + Dockerfile | 11 +- README.md | 31 +- app/api/_lib/client-ip.ts | 1 - app/api/_lib/ip.ts | 3 + app/api/_lib/rate-limit.ts | 10 + app/api/_lib/request-cache.ts | 12 + app/api/tools/[slug]/route.ts | 100 +- app/bufferdash.js/route.ts | 47 + app/globals.css | 19 +- app/layout.tsx | 2 + app/not-found.tsx | 17 + app/page.tsx | 7 +- app/privacy/page.tsx | 5 +- app/robots.ts | 9 + app/sitemap.ts | 24 + app/tools/[slug]/page.tsx | 12 +- components/landing/HeroTerminal.tsx | 10 +- components/landing/SiteChrome.tsx | 3 +- components/tools/ToolExperience.tsx | 70 +- data/tools.ts | 8 +- diagnostics-worker/README.md | 7 +- diagnostics-worker/src/server.js | 17 +- docker-compose.worker.example.yml | 4 +- next.config.mjs | 41 +- package-lock.json | 2561 ++++++++++++++++++++++----- package.json | 15 +- robots.txt | 5 - sitemap.xml | 39 - tests/ip.test.ts | 28 + tests/request-body.test.ts | 26 + 34 files changed, 2596 insertions(+), 599 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 app/bufferdash.js/route.ts create mode 100644 app/not-found.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts delete mode 100644 robots.txt delete mode 100644 sitemap.xml create mode 100644 tests/ip.test.ts create mode 100644 tests/request-body.test.ts diff --git a/.dockerignore b/.dockerignore index e085ee0..980b23f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,16 @@ node_modules .next .git +.DS_Store +.npm-cache +.playwright-cli +.tmp +output +*.tsbuildinfo .env .env.local +.env.* +!.env.example npm-debug.log Dockerfile docker-compose.yml diff --git a/.env.example b/.env.example index 8342465..330ef31 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,17 @@ # Optional. Set this to the hosted documentation URL, such as https://docs.buffer.lol. NEXT_PUBLIC_DOCS_URL= +# Optional. Enables the private BufferDash analytics tracker when both values are set. +# Create the site in BufferDash, then use the generated public site key here. +BUFFERDASH_URL= +BUFFERDASH_SITE_ID= + # Optional production hardening for server-backed diagnostics. # When both Upstash values are set, API rate limits coordinate across serverless replicas. UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= -# Proxy IP headers are trusted automatically in production. Set false to disable. +# Trust proxy IP headers only when your proxy overwrites client-supplied forwarding headers. TRUST_PROXY_HEADERS= TRUSTED_PROXY_PLATFORM= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..059c508 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: Validate buffer.lol + +on: + pull_request: + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 006caaa..5effe30 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,7 +6,23 @@ on: - main jobs: + validate: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build + deploy: + needs: validate runs-on: ubuntu-latest steps: diff --git a/Dockerfile b/Dockerfile index 2b1dbe6..7e18f9e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,14 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 -COPY --from=builder /app ./ +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs EXPOSE 3000 -CMD ["npm", "start"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 6e3331a..005f688 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ buffer.lol is a focused toolbox for checking hosts, domains, headers, certificat | Category | Tools | | --- | --- | -| Networking | Ping, packet loss, traceroute, DNS lookup, HTTP headers, SSL certificate checker, uptime checker, port checker, CIDR calculator, WHOIS/RDAP lookup, redirect checker, robots.txt/sitemap checker | -| IP | What's my IP, IP geolocation, ASN/ISP lookup, user-agent parser | +| Networking | Browser latency, connection stability, traceroute, DNS lookup, HTTP headers, SSL certificate checker, uptime checker, port checker, CIDR calculator, WHOIS/RDAP lookup, redirect checker, robots.txt/sitemap checker | +| IP | What's my IP, IP network lookup, ASN/ISP lookup, user-agent parser | | Developer | JSON formatter, Base64 encoder/decoder, hash generator, UUID generator, timestamp converter, URL parser/encoder, JWT decoder, regex tester | -Ping and packet-loss/stability tests run from the visitor's browser to buffer.lol using repeated HTTPS samples. Traceroute uses the included diagnostics worker because route tracing is not available in browsers or typical serverless runtimes. +Browser latency and connection-stability tests run from the visitor's browser to buffer.lol using repeated HTTPS samples. Traceroute uses the included diagnostics worker because route tracing is not available in browsers or typical serverless runtimes. ## Tech Stack @@ -49,6 +49,7 @@ Open `http://localhost:3000`. | `npm run dev` | Start the local Next.js app. | | `npm run build` | Build the production app. | | `npm run lint` | Run ESLint for `app` and `components`. | +| `npm test` | Run the security and request-boundary test suite. | | `npm run typecheck` | Run TypeScript without emitting files. | ## Environment @@ -60,18 +61,20 @@ Copy `.env.example` to `.env.local` when configuring production-like behavior: | Variable | Required | Description | | --- | --- | --- | | `NEXT_PUBLIC_DOCS_URL` | No | Docs URL used by the app. Leave empty locally or set to `https://docs.buffer.lol` in production. | +| `BUFFERDASH_URL` | No | Public URL of the private BufferDash instance, such as `https://dash.buffer.lol`. Enables analytics only when paired with `BUFFERDASH_SITE_ID`. | +| `BUFFERDASH_SITE_ID` | No | Public site key generated by BufferDash for `buffer.lol`. | | `UPSTASH_REDIS_REST_URL` | No | Enables shared Redis-backed API rate limiting when paired with the token. | | `UPSTASH_REDIS_REST_TOKEN` | No | Upstash REST token for shared rate limiting. | -| `TRUST_PROXY_HEADERS` | No | Proxy IP headers are trusted automatically in production. Set to `false` only if the app is directly exposed without a trusted proxy. | +| `TRUST_PROXY_HEADERS` | No | Set to `true` only when the app is behind a proxy that overwrites client-supplied forwarding headers. | | `TRUSTED_PROXY_PLATFORM` | No | Alternative proxy preset: `vercel` or `cloudflare`. | | `ENABLE_WORKER_TOOLS` | No | Enables the worker-backed traceroute tool. | | `DIAGNOSTICS_WORKER_URL` | No | Base URL for the diagnostics worker. Required when worker tools are enabled. | -| `DIAGNOSTICS_WORKER_TOKEN` | No | Optional bearer token sent to the diagnostics worker. | +| `DIAGNOSTICS_WORKER_TOKEN` | Worker | Required by the diagnostics worker in production and sent as a bearer token by the app. | | `DIAGNOSTICS_MAX_CONCURRENCY` | No | Per-instance cap for live diagnostics work. Defaults to the app fallback when unset. | ## Diagnostics Worker -The `diagnostics-worker/` service powers the traceroute visualizer. Ping and packet-loss/stability tests run in the browser against buffer.lol. +The `diagnostics-worker/` service powers the traceroute visualizer. Browser latency and connection-stability tests run in the browser against buffer.lol. It exposes `POST /api/traceroute`, validates public targets, runs Linux `traceroute` with strict timeouts, and returns JSON to the main app. The worker also keeps server-side ping endpoints available for future/internal use, but the public ping and packet-loss pages do not use them. @@ -85,6 +88,21 @@ DIAGNOSTICS_WORKER_TOKEN=use-a-long-random-secret The worker container needs `NET_RAW` for low-level network diagnostics. See `diagnostics-worker/README.md` for the full compose snippet. +## BufferDash Analytics + +buffer.lol can send page views, outbound clicks, sessions, and custom product events to a private BufferDash instance. + +1. Deploy BufferDash at a private dashboard URL, such as `https://dash.buffer.lol`. +2. In BufferDash, open `/sites`, create a site for `buffer.lol`, and copy the generated public site key. +3. Set these values for the buffer.lol app: + +```env +BUFFERDASH_URL=https://dash.buffer.lol +BUFFERDASH_SITE_ID=buffer-lol-example +``` + +When both values are present, the root layout loads `/bufferdash.js`, which injects `https://dash.buffer.lol/tracker.js` on every page. Leave either value empty to disable analytics. + ## API Server-backed tools use a shared endpoint: @@ -143,6 +161,7 @@ Before publishing, run: ```bash npm run lint npm run typecheck +npm test npm run build ``` diff --git a/app/api/_lib/client-ip.ts b/app/api/_lib/client-ip.ts index 12f279c..37189d6 100644 --- a/app/api/_lib/client-ip.ts +++ b/app/api/_lib/client-ip.ts @@ -45,7 +45,6 @@ export function trustProxyHeaders() { const platform = process.env.TRUSTED_PROXY_PLATFORM?.toLowerCase(); return ( - process.env.NODE_ENV === "production" || platform === "vercel" || platform === "cloudflare" || process.env.VERCEL === "1" || diff --git a/app/api/_lib/ip.ts b/app/api/_lib/ip.ts index bc2cda1..d4f212d 100644 --- a/app/api/_lib/ip.ts +++ b/app/api/_lib/ip.ts @@ -20,7 +20,10 @@ const IPV4_BLOCKED_RANGES: Array<[number, number]> = [ const IPV6_BLOCKED_RANGES: Array<[bigint, number]> = [ [BigInt(0), 128], [BigInt(1), 128], + [ipv6ToBigInt("::"), 96], + [ipv6ToBigInt("::ffff:0:0:0"), 96], [ipv6ToBigInt("64:ff9b::"), 96], + [ipv6ToBigInt("64:ff9b:1::"), 48], [ipv6ToBigInt("100::"), 64], [ipv6ToBigInt("2001::"), 23], [ipv6ToBigInt("2001:2::"), 48], diff --git a/app/api/_lib/rate-limit.ts b/app/api/_lib/rate-limit.ts index c6807a1..f753ad9 100644 --- a/app/api/_lib/rate-limit.ts +++ b/app/api/_lib/rate-limit.ts @@ -19,6 +19,7 @@ type RateLimitResult = { }; const buckets = new Map(); +const MAX_MEMORY_BUCKETS = 10_000; let lastCleanup = Date.now(); export async function checkRateLimit(request: NextRequest, options: RateLimitOptions): Promise { @@ -44,6 +45,7 @@ function checkMemoryRateLimit(key: string, options: RateLimitOptions): RateLimit if (!bucket || bucket.resetAt <= now) { bucket = { count: 0, resetAt: now + options.windowMs }; buckets.set(key, bucket); + trimBuckets(); } const allowed = bucket.count < options.limit; @@ -52,6 +54,14 @@ function checkMemoryRateLimit(key: string, options: RateLimitOptions): RateLimit return toRateLimitResult(allowed, options.limit, Math.max(0, options.limit - bucket.count), bucket.resetAt); } +function trimBuckets() { + while (buckets.size > MAX_MEMORY_BUCKETS) { + const oldestKey = buckets.keys().next().value; + if (typeof oldestKey !== "string") break; + buckets.delete(oldestKey); + } +} + async function checkUpstashRateLimit(key: string, options: RateLimitOptions): Promise { const windowId = Math.floor(Date.now() / options.windowMs); const redisKey = `rate:${key}:${windowId}`; diff --git a/app/api/_lib/request-cache.ts b/app/api/_lib/request-cache.ts index 5545771..ad53881 100644 --- a/app/api/_lib/request-cache.ts +++ b/app/api/_lib/request-cache.ts @@ -5,6 +5,7 @@ type CacheEntry = { const cache = new Map>(); const inflight = new Map>(); +const MAX_CACHE_ENTRIES = 500; let activeRequests = 0; let lastCleanup = Date.now(); @@ -13,6 +14,8 @@ export async function withCache(key: string, ttlMs: number, factory: () => Pr const cached = cache.get(key) as CacheEntry | undefined; if (cached && cached.expiresAt > now) { + cache.delete(key); + cache.set(key, cached); return cached.value; } @@ -20,10 +23,19 @@ export async function withCache(key: string, ttlMs: number, factory: () => Pr const value = await factory(); cache.set(key, { value, expiresAt: Date.now() + ttlMs }); cleanupCache(Date.now()); + trimCache(); return value; }); } +function trimCache() { + while (cache.size > MAX_CACHE_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (typeof oldestKey !== "string") break; + cache.delete(oldestKey); + } +} + export async function dedupe(key: string, factory: () => Promise): Promise { const existing = inflight.get(key) as Promise | undefined; if (existing) return existing; diff --git a/app/api/tools/[slug]/route.ts b/app/api/tools/[slug]/route.ts index d479171..c18c0eb 100644 --- a/app/api/tools/[slug]/route.ts +++ b/app/api/tools/[slug]/route.ts @@ -1,10 +1,11 @@ import { promises as dns } from "node:dns"; import { createHash } from "node:crypto"; -import net from "node:net"; +import net, { type LookupFunction } from "node:net"; import tls from "node:tls"; import { domainToASCII } from "node:url"; import { NextRequest, NextResponse } from "next/server"; -import { getClientIp } from "../../_lib/client-ip"; +import { Agent, type Dispatcher } from "undici"; +import { getClientIp, trustProxyHeaders } from "../../_lib/client-ip"; import { ConcurrencyLimitError, dedupe, withCache, withConcurrencyLimit } from "../../_lib/request-cache"; import { checkRateLimit } from "../../_lib/rate-limit"; import { isPublicIp, normalizeIpLiteral } from "../../_lib/ip"; @@ -60,7 +61,7 @@ export async function POST(request: NextRequest, context: RouteContext) { } enforceSameOrigin(request); - enforceBodySize(request); + enforceDeclaredBodySize(request); const body = await readJsonBody(request); const input = typeof body.input === "string" ? body.input : ""; @@ -175,6 +176,7 @@ async function runWorkerTool(slug: string, input: string, requestId: string) { const workerUrl = process.env.DIAGNOSTICS_WORKER_URL; if (!workerUrl) throw new ApiError("Worker-backed tools are enabled, but DIAGNOSTICS_WORKER_URL is not configured.", 503); + if (!process.env.DIAGNOSTICS_WORKER_TOKEN) throw new ApiError("Worker-backed tools are enabled, but DIAGNOSTICS_WORKER_TOKEN is not configured.", 503); const endpoint = new URL(`/api/${slug}`, workerUrl); const response = await fetch(endpoint, { @@ -183,7 +185,7 @@ async function runWorkerTool(slug: string, input: string, requestId: string) { "Content-Type": "application/json", "User-Agent": USER_AGENT, "X-Request-Id": requestId, - ...(process.env.DIAGNOSTICS_WORKER_TOKEN ? { Authorization: `Bearer ${process.env.DIAGNOSTICS_WORKER_TOKEN}` } : {}) + Authorization: `Bearer ${process.env.DIAGNOSTICS_WORKER_TOKEN}` }, body: JSON.stringify({ input }), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) @@ -215,7 +217,7 @@ function enforceSameOrigin(request: NextRequest) { const allowedHosts = new Set([ requestOrigin.host, request.headers.get("host"), - firstHeaderValue(request.headers.get("x-forwarded-host")) + ...(trustProxyHeaders() ? [firstHeaderValue(request.headers.get("x-forwarded-host"))] : []) ].filter(Boolean)); if (!allowedHosts.has(originUrl.host)) { @@ -227,7 +229,7 @@ function firstHeaderValue(value: string | null) { return value?.split(",")[0]?.trim() || null; } -function enforceBodySize(request: NextRequest) { +function enforceDeclaredBodySize(request: NextRequest) { const contentLength = Number(request.headers.get("content-length") || "0"); if (contentLength > MAX_BODY_BYTES) throw new ApiError("Request body is too large.", 413); } @@ -295,7 +297,6 @@ async function lookupDns(input: string) { async function inspectHeaders(input: string) { const url = normalizeHttpUrl(input); - await resolvePublicHost(url.hostname); const started = Date.now(); let response = await safeFetch(url, { method: "HEAD" }); @@ -315,7 +316,6 @@ async function inspectHeaders(input: string) { async function checkUptime(input: string) { const url = normalizeHttpUrl(input); - await resolvePublicHost(url.hostname); const started = Date.now(); let response = await safeFetch(url, { method: "HEAD" }); @@ -338,8 +338,6 @@ async function checkRedirects(input: string) { const chain = []; for (let hop = 0; hop < 8; hop += 1) { - await resolvePublicHost(currentUrl.hostname); - const started = Date.now(); const response = await safeFetch(currentUrl, { method: "HEAD" }); const location = response.headers.get("location"); @@ -365,7 +363,6 @@ async function checkRedirects(input: string) { async function inspectRobotsAndSitemap(input: string) { const url = normalizeHttpUrl(input); const origin = new URL(url.origin); - await resolvePublicHost(origin.hostname); const robotsUrl = new URL("/robots.txt", origin); const sitemapUrl = new URL("/sitemap.xml", origin); @@ -496,12 +493,34 @@ async function resolveRecord(type: string, resolver: () => Promise) { } } -async function readJsonBody(request: NextRequest) { +export async function readJsonBody(request: NextRequest) { + if (!request.body) return {}; + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { - const body = await request.json(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + + total += value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel().catch(() => undefined); + throw new ApiError("Request body is too large.", 413); + } + + chunks.push(value); + } + + if (!chunks.length) return {}; + const body = JSON.parse(new TextDecoder().decode(Buffer.concat(chunks))); return isRecord(body) ? body : {}; - } catch { - return {}; + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError("Request body must be valid JSON.", 400); } } @@ -669,18 +688,47 @@ function requirePublicIp(input: string) { return raw; } -function safeFetch(url: URL, init: RequestInit) { - return fetch(url, { - ...init, - redirect: "manual", - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - headers: { - Accept: "*/*", - "User-Agent": USER_AGENT, - ...(init.headers ?? {}) - } - }).catch((error: unknown) => { +async function safeFetch(url: URL, init: RequestInit) { + const addresses = await resolvePublicHost(url.hostname); + const selected = addresses[0]; + const dispatcher = createPinnedDispatcher(selected); + + try { + const response = await fetch(url, { + ...init, + redirect: "manual", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { + Accept: "*/*", + "User-Agent": USER_AGENT, + ...(init.headers ?? {}) + }, + dispatcher + } as RequestInit & { dispatcher: Dispatcher }); + + if ((init.method || "GET").toUpperCase() === "HEAD") return response; + + const body = await readLimitedText(response, MAX_TEXT_BYTES); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } catch (error) { throw fetchErrorToApiError(error); + } finally { + await dispatcher.close().catch(() => undefined); + } +} + +function createPinnedDispatcher(selected: LookupAddress) { + return new Agent({ + connect: { + lookup: ((_hostname, options, callback) => { + if (options.all) callback(null, [selected]); + else callback(null, selected.address, selected.family); + }) as LookupFunction + } }); } diff --git a/app/bufferdash.js/route.ts b/app/bufferdash.js/route.ts new file mode 100644 index 0000000..fcf76cd --- /dev/null +++ b/app/bufferdash.js/route.ts @@ -0,0 +1,47 @@ +const disabledLoader = "/* BufferDash disabled: BUFFERDASH_URL and BUFFERDASH_SITE_ID are required. */"; + +export const dynamic = "force-dynamic"; + +function trackerUrl() { + const value = process.env.BUFFERDASH_URL; + if (!value) return null; + + try { + const url = new URL(value); + const isLocalDevelopment = process.env.NODE_ENV !== "production" && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname); + if (url.protocol !== "https:" && !isLocalDevelopment) return null; + return `${url.toString().replace(/\/$/, "")}/tracker.js`; + } catch { + return null; + } +} + +export function GET() { + const src = trackerUrl(); + const siteId = process.env.BUFFERDASH_SITE_ID; + + if (!src || !siteId || siteId.length > 128 || !/^[a-zA-Z0-9._-]+$/.test(siteId)) { + return new Response(disabledLoader, { + headers: { + "content-type": "application/javascript; charset=utf-8", + "cache-control": "no-store" + } + }); + } + + const loader = ` +(function () { + var script = document.createElement("script"); + script.defer = true; + script.src = ${JSON.stringify(src)}; + script.setAttribute("data-site-id", ${JSON.stringify(siteId)}); + document.head.appendChild(script); +})();`; + + return new Response(loader, { + headers: { + "content-type": "application/javascript; charset=utf-8", + "cache-control": "public, max-age=300, stale-while-revalidate=3600" + } + }); +} diff --git a/app/globals.css b/app/globals.css index a87a38f..c9de67b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -8,7 +8,7 @@ --line-strong: rgba(255, 255, 255, 0.17); --text: #f5f3ff; --muted: #9996a8; - --faint: #646171; + --faint: #858191; --purple: #9b87f5; --purple-bright: #b9a9ff; --purple-deep: #7557e7; @@ -58,6 +58,10 @@ button, input, textarea, select { font: inherit; } button { color: inherit; } h1, h2, h3, p, dl, dd { margin: 0; } +.skip-link { position: fixed; top: 0.75rem; left: 0.75rem; z-index: 100; padding: 0.65rem 0.8rem; border-radius: 8px; color: #0d0a18; background: var(--purple-bright); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; transform: translateY(-160%); transition: transform 150ms ease; } +.skip-link:focus { transform: translateY(0); } +a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--purple-bright); outline-offset: 3px; } + .grid-overlay { position: fixed; inset: 0; @@ -300,10 +304,7 @@ main, .site-footer::before { position: absolute; top: 0; left: 50%; z-index: -1; width: 100vw; height: 100%; content: ""; transform: translateX(-50%); background: rgba(5, 5, 8, 0.48); } .footer-brand p { margin-top: 1rem; color: var(--faint); font-size: 0.74rem; } .footer-links { display: flex; flex-direction: column; gap: 0.75rem; color: var(--muted); font-size: 0.72rem; } -.footer-bottom { grid-column: 1 / -1; display: flex; justify-content: space-between; margin-top: 2.5rem; padding-top: 1.3rem; border-top: 1px solid var(--line); color: #514e59; font-family: var(--font-mono); font-size: 0.57rem; } -.system-status { display: flex; align-items: center; gap: 0.45rem; } -.system-status i { width: 5px; height: 5px; border-radius: 50%; background: var(--green); } - +.footer-bottom { grid-column: 1 / -1; display: flex; justify-content: space-between; margin-top: 2.5rem; padding-top: 1.3rem; border-top: 1px solid var(--line); color: var(--faint); font-family: var(--font-mono); font-size: 0.64rem; } /* Tool pages */ .tool-page { min-height: calc(100vh - 77px); padding: 3rem 0 7rem; } .breadcrumbs { display: flex; flex-wrap: wrap; gap: 0.55rem; align-items: center; color: var(--faint); font-size: 0.62rem; } @@ -347,6 +348,14 @@ main, .result-list dd { min-width: 0; color: #d5d1dc; overflow-wrap: anywhere; } .result-note { margin-bottom: 0.85rem; color: var(--muted); font-size: 0.72rem; line-height: 1.6; } .stacked-output { display: grid; gap: 1rem; } +.result-actions { display: flex; justify-content: flex-end; } +.copy-button { min-height: 32px; padding: 0 0.7rem; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: rgba(255, 255, 255, 0.025); font-family: var(--font-mono); font-size: 0.62rem; cursor: pointer; } +.copy-button:hover, .copy-button:focus-visible { border-color: rgba(155, 135, 245, 0.58); color: var(--purple-bright); outline: none; } +.structured-result { gap: 0.25rem; } +.structured-result .result-list { margin-top: -0.35rem; } +.structured-list { display: grid; gap: 0.35rem; padding-left: 1.2rem; margin: 0; } +.structured-list li::marker { color: var(--faint); } +.empty-value { color: var(--faint); } .output-heading { margin-bottom: 0.45rem; color: var(--muted); font-family: var(--font-mono); font-size: 0.66rem; font-weight: 400; text-transform: uppercase; } .inline-result-list, .match-list { padding: 0; margin: 0; list-style: none; } .inline-result-list { display: grid; gap: 0.35rem; } diff --git a/app/layout.tsx b/app/layout.tsx index 46c0f62..064e58e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import Script from "next/script"; import "./globals.css"; export const metadata: Metadata = { @@ -47,6 +48,7 @@ export default function RootLayout({ {children} +