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(); + }); +});