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.
This commit is contained in:
Richard R 2026-05-31 16:41:47 -06:00
parent 27267e5ffb
commit 3c08b9e406
4 changed files with 42 additions and 9 deletions

View file

@ -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",
},

View file

@ -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<string>();

9
src/lib/shared/urls.ts Normal file
View file

@ -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;
}
}

View file

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