From 3c08b9e4061915906b22f8d2ab7d7477327df6b3 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 16:41:47 -0600 Subject: [PATCH] feat(shared): centralize origin validation logic and update metadata usage Move origin extraction to shared utility for consistent validation across server and client modules. Update layout metadata to use the shared origin helper, improving maintainability and deployment flexibility. Add unit tests for layout metadata to ensure correct origin handling. --- src/app/layout.tsx | 6 ++++- src/lib/server/auth/auth.ts | 9 +------- src/lib/shared/urls.ts | 9 ++++++++ tests/unit/layout-metadata.vitest.spec.ts | 27 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 src/lib/shared/urls.ts create mode 100644 tests/unit/layout-metadata.vitest.spec.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 20d08a8..c4487b7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Figtree } from "next/font/google"; import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics"; import { CookieConsentBanner } from "@/components/CookieConsentBanner"; import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config"; +import { tryGetOrigin } from "@/lib/shared/urls"; import pkg from "../../package.json"; const figtree = Figtree({ @@ -34,13 +35,16 @@ export const viewport: Viewport = { viewportFit: "cover", }; +const defaultOrigin = "https://openreader.richardr.dev"; +const validatedOrigin = tryGetOrigin(process.env.BASE_URL) || defaultOrigin; + export const metadata: Metadata = { title: { default: "OpenReader", template: "%s | OpenReader", }, manifest: "/manifest.json", - metadataBase: new URL(process.env.BASE_URL || "https://openreader.richardr.dev"), + metadataBase: new URL(validatedOrigin), verification: { google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U", }, diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 285d811..c3dd2cf 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -13,6 +13,7 @@ import * as authSchemaSqlite from "@/db/schema_auth_sqlite"; import * as authSchemaPostgres from "@/db/schema_auth_postgres"; import { hashForLog, serverLogger } from '@/lib/server/logger'; import { logDegraded, logServerError } from '@/lib/server/errors/logging'; +import { tryGetOrigin } from "@/lib/shared/urls"; // Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded // lazily via dynamic import() inside the beforeDelete / onLinkAccount @@ -20,14 +21,6 @@ import { logDegraded, logServerError } from '@/lib/server/errors/logging'; // ... -function tryGetOrigin(url: string | undefined): string | null { - if (!url) return null; - try { - return new URL(url).origin; - } catch { - return null; - } -} function getTrustedOrigins(): string[] { const origins = new Set(); diff --git a/src/lib/shared/urls.ts b/src/lib/shared/urls.ts new file mode 100644 index 0000000..e76e2f9 --- /dev/null +++ b/src/lib/shared/urls.ts @@ -0,0 +1,9 @@ +export function tryGetOrigin(url: string | undefined): string | null { + if (!url) return null; + try { + const origin = new URL(url).origin; + return origin === 'null' ? null : origin; + } catch { + return null; + } +} diff --git a/tests/unit/layout-metadata.vitest.spec.ts b/tests/unit/layout-metadata.vitest.spec.ts new file mode 100644 index 0000000..5a52625 --- /dev/null +++ b/tests/unit/layout-metadata.vitest.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { tryGetOrigin } from '../../src/lib/shared/urls'; + +describe('tryGetOrigin utility function', () => { + test('returns null for undefined, null, or empty string', () => { + expect(tryGetOrigin(undefined)).toBeNull(); + expect(tryGetOrigin('')).toBeNull(); + }); + + test('extracts origin from valid HTTP/HTTPS URLs', () => { + expect(tryGetOrigin('http://localhost:3000')).toBe('http://localhost:3000'); + expect(tryGetOrigin('https://openreader.richardr.dev')).toBe('https://openreader.richardr.dev'); + expect(tryGetOrigin('https://my-custom-domain.com/some/path?query=1')).toBe('https://my-custom-domain.com'); + }); + + test('returns null for malformed URLs/strings without protocols', () => { + expect(tryGetOrigin('malformed-url')).toBeNull(); + expect(tryGetOrigin('localhost:3000')).toBeNull(); + expect(tryGetOrigin('my-custom-domain.com')).toBeNull(); + }); + + test('returns null for invalid protocols/schemes', () => { + expect(tryGetOrigin('ftp://some-server.com')).toBe('ftp://some-server.com'); // standard URL supports ftp + expect(tryGetOrigin('invalid://foo')).toBeNull(); // non-standard/unsupported origins return null + expect(tryGetOrigin('http://[invalid-ipv6]')).toBeNull(); + }); +});