From a6af361b985367a500a908c3fcfd824adad7e82f Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Mon, 19 Oct 2026 22:30:34 -0500 Subject: [PATCH] refactor: serve images directly from the public media host --- .env.example | 8 +- README.md | 12 +- docs/content-pages.md | 2 +- docs/leadership-page.md | 2 +- docs/media-proxy.md | 25 -- docs/media.md | 17 + src/env.ts | 12 +- src/lib/server/companies/mapper.ts | 4 +- src/lib/server/directory/mapper.ts | 5 +- src/lib/server/groups/mapper.ts | 6 +- src/lib/server/home/mapper.ts | 9 +- src/lib/server/leadership/mapper.ts | 4 +- src/lib/server/media/config.ts | 111 +----- src/lib/server/media/mapper.ts | 74 +--- src/lib/server/media/proxy.ts | 319 ----------------- src/lib/server/media/runtime.ts | 18 +- src/lib/server/media/validation.ts | 145 -------- src/lib/server/member/mapper.ts | 4 +- src/lib/server/pages/html.ts | 16 +- src/lib/server/pages/mapper.ts | 4 +- src/routes/account/+page.server.ts | 4 +- src/routes/account/avatar/+server.ts | 4 +- src/routes/media/[...key]/+server.ts | 37 -- tests/companies/companies.test.ts | 4 +- tests/directory/directory-load.test.ts | 4 +- tests/groups/fixture.ts | 2 +- tests/groups/groups.test.ts | 2 +- tests/home/home-load.test.ts | 2 +- tests/home/home-mapper.test.ts | 12 +- tests/jobs/fixture.ts | 2 +- tests/leadership/fixture.ts | 2 +- tests/leadership/leadership.test.ts | 2 +- tests/media/media-proxy.test.ts | 474 ------------------------- tests/media/public-media.test.ts | 25 ++ tests/member/fixture.ts | 2 +- tests/member/mapper.test.ts | 6 +- tests/pages/fixture.ts | 2 +- tests/pages/legacy-pages.test.ts | 2 +- tests/pages/pages.test.ts | 6 +- 39 files changed, 138 insertions(+), 1253 deletions(-) delete mode 100644 docs/media-proxy.md create mode 100644 docs/media.md delete mode 100644 src/lib/server/media/proxy.ts delete mode 100644 src/lib/server/media/validation.ts delete mode 100644 src/routes/media/[...key]/+server.ts delete mode 100644 tests/media/media-proxy.test.ts create mode 100644 tests/media/public-media.test.ts diff --git a/.env.example b/.env.example index 8b440ebf..b01a221f 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,9 @@ CMS_INTERNAL_ORIGIN=http://127.0.0.1:5099 # for reset-email links. This is not a browser CORS allowlist. # CMS_MEMBER_COOKIE_NAME defaults to .AspNetCore.Identity.Application. -# Server-only. Set these only when testing fictional local media through the same-origin proxy. -# Do not point ordinary local development at production media. -# MEDIA_UPSTREAM_ORIGIN=http://127.0.0.1:5099 -# MEDIA_UPSTREAM_PATH_PREFIX=/media/ -# MEDIA_SOURCE_PUBLIC_ORIGIN=https://media.sgf.dev +# Browser-accessible media base. Local development uses the local CMS, not production storage. +# Production uses https://media.sgf.dev/ without a /media/ prefix. +MEDIA_PUBLIC_URL=http://127.0.0.1:5099/media/ # Adapter-node request cap: 8 MiB avatar plus multipart overhead. BODY_SIZE_LIMIT=9M diff --git a/README.md b/README.md index b5196ec6..e7355110 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ The public [sgf.dev](https://sgf.dev) frontend uses SvelteKit 3, Svelte 5 and Tailwind CSS 4. The [Umbraco backend](https://github.com/sgfdevs/cms.sgf.dev) -runs separately. Page SSR, generated API clients, the same-origin media proxy -and member forms already call the CMS from frontend server code. Browsers do -not call the CMS member bridge directly. +runs separately. Page SSR and member forms call the CMS from frontend server +code. Browsers load images directly from the public media host, never through +a frontend proxy. Browsers do not call the CMS member bridge directly. ## Requirements and configuration @@ -25,8 +25,10 @@ privately, never in Git, client code or Docker build arguments: - `CMS_MEMBER_BRIDGE_SECRET` must match backend `SGFDevs__MemberBridge__Secret`. Member calls fail closed without it. Match `CMS_MEMBER_COOKIE_NAME` only if the CMS cookie name has been customized. -- Media proxy origins and path fences are private server settings. Point local - media upstreams only at fictional local media, not production storage. +- `MEDIA_PUBLIC_URL` is the browser-accessible media base, defaulting to + `https://media.sgf.dev/`. Use `http://127.0.0.1:5099/media/` locally. Native CMS + media paths map to that base; existing public media URLs keep their crop and + sizing parameters. See `docs/media.md`. - Backend `SGFDevs__MemberBridge__FrontendOrigin` must be the frontend origin, for example `http://127.0.0.1:3000`, so reset emails link to Kit. diff --git a/docs/content-pages.md b/docs/content-pages.md index ec7e0aeb..4979310e 100644 --- a/docs/content-pages.md +++ b/docs/content-pages.md @@ -10,7 +10,7 @@ Only the page name, title tag, description, approved OG image and two known bloc The installed Umbraco 18.2 XML API documentation for `MarkdownEditorValueConverter.ConvertIntermediateToDeliveryApiObject` specifies a Markdown string, unlike its Razor HTML conversion. A small standalone `marked` parser converts that string, then the existing maintained `sanitize-html` package sanitizes it. Native rich-text markup goes directly to the sanitizer. Rich-text embedded blocks are not supported by the reviewed page datatype and fail closed. No Markdown/HTML UI framework or custom CMS converter was added. -The sanitizer retains headings, lists, tables, text formatting and safe internal or HTTP(S) links. It removes scripts, event handlers, inline styles, forms and embeds. Images and OG images use the existing JPEG/PNG same-origin mapping. PDF links use the existing public document origin separately, never the image proxy. Relative legacy PDF paths map from `/media/{folder}/{file}.pdf` to `{MEDIA_SOURCE_PUBLIC_ORIGIN}/{folder}/{file}.pdf`; existing public HTTP(S) hrefs remain links. No document fetch or generic media proxy was added. +The sanitizer retains headings, lists, tables, text formatting and safe internal or HTTP(S) links. It removes scripts, event handlers, inline styles, forms and embeds. Images, OG images and PDF downloads use the public media host configured by `MEDIA_PUBLIC_URL`. Native `/media/` paths resolve against that base. The frontend does not fetch media. See `media.md`. About retains the actual hard-coded Razor body, registration and Discord links, sponsorship/conduct cards and governance documents. It only renders after the matching CMS page loads. Other pages retain their name heading and ordered block layout within the existing shared shell/theme. Page metadata overrides shell defaults without duplicate description/OG tags; canonical origin continues to use the existing validated site-origin helper and a fixed content path. diff --git a/docs/leadership-page.md b/docs/leadership-page.md index b31bf38b..26de8731 100644 --- a/docs/leadership-page.md +++ b/docs/leadership-page.md @@ -4,4 +4,4 @@ The page retains the legacy officers, Board Of Directors, History and board application layout using existing theme tokens, shell and static artwork. The three member selections retain their CMS order. The native model and legacy view contain no year grouping, so no years are inferred. History retains its blank-title "Board Member" fallback and a local heading link instead of an invented profile destination. -Only officer biographies render HTML, through the existing page sanitizer. Names and titles remain escaped Svelte text. Profile paths derive from validated public usernames. Avatars use the existing same-origin media mapper or pipey placeholder. PageData does not retain raw properties, IDs, keys or security values. Root metadata handling accepts the leadership title/path while preserving private configuration and canonical policy. +Only officer biographies render HTML, through the existing page sanitizer. Names and titles remain escaped Svelte text. Profile paths derive from validated public usernames. Avatars use the public media host or Pipey fallback. PageData does not retain raw properties, IDs, keys or security values. Root metadata handling accepts the leadership title/path while preserving private configuration and canonical policy. diff --git a/docs/media-proxy.md b/docs/media-proxy.md deleted file mode 100644 index 636851da..00000000 --- a/docs/media-proxy.md +++ /dev/null @@ -1,25 +0,0 @@ -# Media proxy foundation - -The SvelteKit frontend serves CMS media through same-origin `/media/{key}` URLs. The proxy is anonymous and read-only. It supports `GET` and explicit `HEAD` only. - -DTO media URLs are mapped on the server from two fixed forms: - -- `https://media.sgf.dev/{folder}/{file}` -- `/media/{folder}/{file}` - -`/images/pipey.jpg` is the only static fallback passthrough. Public fixture captures under `/tmp/sgf-public-parity-fixtures` are validation inputs only, not runtime fallback media. - -The route uses server-only configuration: - -- `MEDIA_UPSTREAM_ORIGIN`, required before the route fetches media -- `MEDIA_UPSTREAM_PATH_PREFIX`, required for non-production origins -- `MEDIA_SOURCE_PUBLIC_ORIGIN`, optional source DTO validation origin, defaulting to `https://media.sgf.dev` -- `CMS_INTERNAL_ORIGIN`, optional accepted CMS source prefix for `/media/...` DTO values - -Root upstream paths are allowed only for `https://media.sgf.dev`. Local CMS or local S3/SeaweedFS testing needs a specific prefix such as `/media/` or a bootstrap bucket path. Ordinary local development has no production fetch default. - -The proxy builds a fresh upstream `Request` with `credentials: 'omit'`, disabled redirects, no caller headers, no cookies, no auth, no range, and no forwarded/referrer/origin headers. It checks the final origin, path fence, method, redirect policy, credential policy, and headers immediately before fetch. - -Path and query handling is intentionally narrow. Media keys must have exactly two safe path segments. Encoded slashes, backslashes, dot escapes, double escapes, NUL, userinfo-looking values, protocols, duplicate slashes, bad percent escapes, and dot segments are rejected. Query parameters are limited to `width` and `v`, with bounds and duplicate checks. Current public fixtures use only JPEG/PNG media, `width`, and `v`; SVG/PDF proxying needs a separate reviewed change. - -Upstream responses are accepted only for JPEG/PNG image content types and successful, non-redirect responses. The route enforces an 8 MiB body cap while reading the stream, rejects spoofed or oversized `Content-Length`, rejects encoded upstream representations, strips upstream private headers, sets `nosniff`, and emits only safe response headers. diff --git a/docs/media.md b/docs/media.md new file mode 100644 index 00000000..0f3c4dd5 --- /dev/null +++ b/docs/media.md @@ -0,0 +1,17 @@ +# Public media + +Browsers load images and PDF links directly from the CMS public media host. +The frontend does not fetch, resize, stream or cache media. + +Set `MEDIA_PUBLIC_URL` to the browser-accessible media base. Production uses +`https://media.sgf.dev/`, without a `/media/` prefix. Local development uses +`http://127.0.0.1:5099/media/`. API access still uses `CMS_INTERNAL_ORIGIN` privately. + +The mapper preserves URLs already under the public base. Relative `/media/` +paths and internal CMS media URLs resolve against that base. It retains sizing, +cropping and version query parameters. Images support JPEG, PNG, GIF, SVG, WebP +and AVIF. Invalid or unapproved image URLs are omitted or use the existing +Pipey fallback. HTML sanitization remains in place. + +The former `/media/[...key]` route and `MEDIA_UPSTREAM_*` settings are removed. +Replace `MEDIA_SOURCE_PUBLIC_ORIGIN` with `MEDIA_PUBLIC_URL` when deploying. diff --git a/src/env.ts b/src/env.ts index c49af4ab..dfb06b9e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,16 +17,8 @@ export const variables = defineEnvVars({ description: 'Server-only member application cookie name. Match the CMS if its default is customized.', schema: (value) => value || '.AspNetCore.Identity.Application' }, - MEDIA_SOURCE_PUBLIC_ORIGIN: { - description: 'Private validation origin for public SGF media DTO URLs. Defaults to https://media.sgf.dev.', - schema: (value) => (value ? value : undefined) - }, - MEDIA_UPSTREAM_ORIGIN: { - description: 'Private fixed upstream origin for same-origin SGF media proxy requests. Required before /media/* is fetched.', - schema: (value) => (value ? value : undefined) - }, - MEDIA_UPSTREAM_PATH_PREFIX: { - description: 'Private upstream media namespace fence. Use / only for https://media.sgf.dev; local/CMS/S3 origins need a specific prefix.', + MEDIA_PUBLIC_URL: { + description: 'Browser-accessible media base URL. Defaults to https://media.sgf.dev/. Use the local CMS /media/ URL for local development.', schema: (value) => (value ? value : undefined) }, PUBLIC_SITE_ORIGIN: { diff --git a/src/lib/server/companies/mapper.ts b/src/lib/server/companies/mapper.ts index 87c857e9..d0be04e0 100644 --- a/src/lib/server/companies/mapper.ts +++ b/src/lib/server/companies/mapper.ts @@ -1,7 +1,7 @@ import { marked } from 'marked'; import sanitizeHtml from 'sanitize-html'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic } from '../media/mapper'; import { sanitizePageHtml } from '../pages/html'; import { safeMemberHttpUrl } from '../member/links'; import { isCompanyPath } from './paths'; @@ -22,7 +22,7 @@ function optional(value: unknown): string | null { function image(value: unknown, media: MediaSourceConfig): string | null { if (value == null) return null; if (!Array.isArray(value) || value.length > 1) throw new CompanyDataError(); - return value.length ? mapMediaUrlToSameOrigin(text(record(value[0]).url, 2000), media) : null; + return value.length ? mapMediaUrlToPublic(text(record(value[0]).url, 2000), media) : null; } // Parse only iframe src, then rebuild a trusted URL. Never return embed HTML or attributes. diff --git a/src/lib/server/directory/mapper.ts b/src/lib/server/directory/mapper.ts index 71cb8173..44f1011a 100644 --- a/src/lib/server/directory/mapper.ts +++ b/src/lib/server/directory/mapper.ts @@ -1,7 +1,6 @@ import type { components } from '../api/generated/sgfPublicApiSchema'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; -import { STATIC_MEDIA_PASSTHROUGH } from '../media/validation'; +import { mapMediaUrlToPublic, STATIC_MEDIA_PASSTHROUGH } from '../media/mapper'; export type PublicDirectoryMemberDto = components['schemas']['PublicDirectoryMemberDto']; export type PublicSkillFilterDto = components['schemas']['PublicSkillFilterDto']; @@ -51,7 +50,7 @@ function mapDirectoryMember(value: unknown, mediaConfig: MediaSourceConfig, path return { name: requireString(source.name, `${path}.name`), location: requireString(source.location, `${path}.location`), - image: mapMediaUrlToSameOrigin(requireString(source.image, `${path}.image`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, + image: mapMediaUrlToPublic(requireString(source.image, `${path}.image`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, url: requireInternalPath(source.url, `${path}.url`), tags: mapStringArray(source.tags, `${path}.tags`) }; diff --git a/src/lib/server/groups/mapper.ts b/src/lib/server/groups/mapper.ts index df9a3541..9cda72be 100644 --- a/src/lib/server/groups/mapper.ts +++ b/src/lib/server/groups/mapper.ts @@ -1,7 +1,7 @@ import type { components } from '../api/generated/sgfPublicApiSchema'; import { isPublicGroupSlug, isPublicMemberUsername } from '../api/factory'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic } from '../media/mapper'; import { safeMemberHttpUrl } from '../member/links'; import { sanitizePageHtml } from '../pages/html'; import { mapPresenter } from '../home/mapper'; @@ -51,7 +51,7 @@ export function mapGroup(value: PublicGroupDto, media: MediaSourceConfig) { return { name: text(group.name), path: groupPath(group.path), aboutHtml: sanitizePageHtml(optional(group.aboutHtml) ?? '', media), - image: mapMediaUrlToSameOrigin(optional(group.imageUrl), media), + image: mapMediaUrlToPublic(optional(group.imageUrl), media), location: optional(group.location), establishedText: optional(group.establishedText), websiteUrl, socials, skills: array(group.skills).map(value => { const skill = record(value); @@ -63,7 +63,7 @@ export function mapGroup(value: PublicGroupDto, media: MediaSourceConfig) { const leader = record(value); return { name: text(leader.name), listLabel: text(leader.listLabel), location: text(leader.location), - image: mapMediaUrlToSameOrigin(text(leader.imageUrl), media), path: profilePath(leader.profilePath), + image: mapMediaUrlToPublic(text(leader.imageUrl), media), path: profilePath(leader.profilePath), tags: array(leader.tags).map(text) }; }), diff --git a/src/lib/server/home/mapper.ts b/src/lib/server/home/mapper.ts index 17c2d2ad..cf6095ef 100644 --- a/src/lib/server/home/mapper.ts +++ b/src/lib/server/home/mapper.ts @@ -1,7 +1,6 @@ import type { components } from '../api/generated/sgfPublicApiSchema'; import type { MediaSourceConfig } from '../media/config'; -import { STATIC_MEDIA_PASSTHROUGH } from '../media/validation'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { STATIC_MEDIA_PASSTHROUGH, mapMediaUrlToPublic } from '../media/mapper'; export type PublicDirectoryMemberDto = components['schemas']['PublicDirectoryMemberDto']; export type PublicHomeDevNightDto = components['schemas']['PublicHomeDevNightDto']; @@ -123,7 +122,7 @@ export function mapPresenter(value: unknown, mediaConfig: MediaSourceConfig, pat const source = requireRecord(value, path); return { name: requireString(source.name, `${path}.name`), - imageUrl: mapMediaUrlToSameOrigin(requireString(source.imageUrl, `${path}.imageUrl`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, + imageUrl: mapMediaUrlToPublic(requireString(source.imageUrl, `${path}.imageUrl`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, profilePath: mapOptionalInternalPath(source.profilePath, `${path}.profilePath`), tags: mapStringArray(source.tags, `${path}.tags`) }; @@ -153,7 +152,7 @@ function mapDirectoryMember(value: unknown, mediaConfig: MediaSourceConfig, path return { name: requireString(source.name, `${path}.name`), location: requireString(source.location, `${path}.location`), - image: mapMediaUrlToSameOrigin(requireString(source.image, `${path}.image`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, + image: mapMediaUrlToPublic(requireString(source.image, `${path}.image`), mediaConfig) ?? STATIC_MEDIA_PASSTHROUGH, url: requireInternalPath(source.url, `${path}.url`), tags: mapStringArray(source.tags, `${path}.tags`) }; @@ -165,7 +164,7 @@ function mapSponsor(value: unknown, mediaConfig: MediaSourceConfig, path: string return { name: requireString(source.name, `${path}.name`), path: requireInternalPath(source.path, `${path}.path`), - logoUrl: mapMediaUrlToSameOrigin(optionalString(source.logoUrl, `${path}.logoUrl`), mediaConfig), + logoUrl: mapMediaUrlToPublic(optionalString(source.logoUrl, `${path}.logoUrl`), mediaConfig), websiteUrl, websiteLabel: websiteUrl ? optionalString(source.websiteLabel, `${path}.websiteLabel`) : null, isFoundingSponsor: requireBoolean(source.isFoundingSponsor, `${path}.isFoundingSponsor`) diff --git a/src/lib/server/leadership/mapper.ts b/src/lib/server/leadership/mapper.ts index 49216b6c..b777a8d8 100644 --- a/src/lib/server/leadership/mapper.ts +++ b/src/lib/server/leadership/mapper.ts @@ -1,7 +1,7 @@ import type { components } from '../api/generated/sgfPublicApiSchema'; import { isPublicMemberUsername } from '../api/factory'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic } from '../media/mapper'; import { sanitizePageHtml } from '../pages/html'; export type PublicLeadershipDto = components['schemas']['PublicLeadershipDto']; @@ -27,7 +27,7 @@ export function mapLeadership(value: PublicLeadershipDto, media: MediaSourceConf if (!isPublicMemberUsername(member.username)) throw new LeadershipDataError(); return { name: text(member.name), path: `/member/${member.username}`, - image: mapMediaUrlToSameOrigin(text(member.imageUrl), media) ?? '/images/pipey.jpg', + image: mapMediaUrlToPublic(text(member.imageUrl), media) ?? '/images/pipey.jpg', officerTitle: includeTitle ? optional(member.officerTitle) : null, officerBio: includeBio ? sanitizePageHtml(optional(member.officerBio) ?? '', media) : '' }; diff --git a/src/lib/server/media/config.ts b/src/lib/server/media/config.ts index 3bfadc18..ca9cc4dc 100644 --- a/src/lib/server/media/config.ts +++ b/src/lib/server/media/config.ts @@ -1,26 +1,8 @@ -import { MEDIA_MAX_BYTES_DEFAULT, MEDIA_TIMEOUT_MS_DEFAULT, decodeSafePathSegment, hasInvalidPercentEscape } from './validation'; - export type MediaSourceConfig = { - publicSourceOrigin: string; + publicMediaUrl: string; cmsInternalOrigin?: string; }; -export type MediaProxyConfig = MediaSourceConfig & { - upstreamOrigin: string; - upstreamPathPrefix: string; - maxBytes: number; - timeoutMs: number; -}; - -export type MediaEnvironment = { - CMS_INTERNAL_ORIGIN?: string; - MEDIA_SOURCE_PUBLIC_ORIGIN?: string; - MEDIA_UPSTREAM_ORIGIN?: string; - MEDIA_UPSTREAM_PATH_PREFIX?: string; -}; - -const defaultPublicMediaOrigin = 'https://media.sgf.dev'; - export class PrivateMediaConfigError extends Error { constructor(message: string) { super(message); @@ -28,90 +10,13 @@ export class PrivateMediaConfigError extends Error { } } -function mediaConfigError(message: string): PrivateMediaConfigError { - return new PrivateMediaConfigError(message); -} - -export function readMediaSourceConfigFromEnv(env: MediaEnvironment): MediaSourceConfig { - return { - publicSourceOrigin: parseOrigin(env.MEDIA_SOURCE_PUBLIC_ORIGIN || defaultPublicMediaOrigin, 'MEDIA_SOURCE_PUBLIC_ORIGIN'), - cmsInternalOrigin: env.CMS_INTERNAL_ORIGIN ? parseOrigin(env.CMS_INTERNAL_ORIGIN, 'CMS_INTERNAL_ORIGIN') : undefined - }; -} - -export function readMediaProxyConfigFromEnv(env: MediaEnvironment): MediaProxyConfig { - if (!env.MEDIA_UPSTREAM_ORIGIN) { - throw mediaConfigError('MEDIA_UPSTREAM_ORIGIN is required before proxying SGF media.'); - } - - const upstreamOrigin = parseOrigin(env.MEDIA_UPSTREAM_ORIGIN, 'MEDIA_UPSTREAM_ORIGIN'); - return { - ...readMediaSourceConfigFromEnv(env), - upstreamOrigin, - upstreamPathPrefix: parseUpstreamPathPrefix(env.MEDIA_UPSTREAM_PATH_PREFIX, upstreamOrigin), - maxBytes: MEDIA_MAX_BYTES_DEFAULT, - timeoutMs: MEDIA_TIMEOUT_MS_DEFAULT - }; -} - -export function parseOrigin(input: string, name: string): string { - if (input.startsWith('//')) { - throw mediaConfigError(`${name} must include http:// or https://.`); - } - +export function readMediaSourceConfigFromEnv(env: { MEDIA_PUBLIC_URL?: string; CMS_INTERNAL_ORIGIN?: string }): MediaSourceConfig { let url: URL; - try { - url = new URL(input); - } catch { - throw mediaConfigError(`${name} must be a valid origin.`); - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw mediaConfigError(`${name} must use http or https.`); - } - if (url.username || url.password) { - throw mediaConfigError(`${name} must not include credentials.`); - } - if (url.pathname !== '/' || url.search || url.hash) { - throw mediaConfigError(`${name} must be an origin only, with no path, query, or hash.`); - } - - return url.origin; -} - -export function parseUpstreamPathPrefix(input: string | undefined, upstreamOrigin: string): string { - const prefix = input || (upstreamOrigin === defaultPublicMediaOrigin ? '/' : undefined); - if (!prefix) { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX is required for non-production media origins.'); - } - if (!prefix.startsWith('/')) { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX must start with /.'); - } - if (prefix.includes('?') || prefix.includes('#') || prefix.includes('\\')) { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX must be a plain path prefix.'); - } - if (prefix !== '/' && !prefix.endsWith('/')) { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX must end with /.'); - } - if (prefix === '/') { - assertRootPrefixAllowed(upstreamOrigin); - return prefix; - } - if (prefix.includes('//') || hasInvalidPercentEscape(prefix)) { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX must not contain empty segments or invalid percent escapes.'); - } - - try { - for (const segment of prefix.slice(1, -1).split('/')) { - decodeSafePathSegment(segment); - } - } catch { - throw mediaConfigError('MEDIA_UPSTREAM_PATH_PREFIX contains an unsafe segment.'); - } - return prefix; -} - -function assertRootPrefixAllowed(upstreamOrigin: string): void { - if (upstreamOrigin !== defaultPublicMediaOrigin) { - throw mediaConfigError('A root media upstream path is allowed only for the dedicated public media origin.'); + try { url = new URL(env.MEDIA_PUBLIC_URL || 'https://media.sgf.dev/'); } + catch { throw new PrivateMediaConfigError('MEDIA_PUBLIC_URL must be a valid media base URL.'); } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new PrivateMediaConfigError('MEDIA_PUBLIC_URL must be an HTTP(S) media base URL without credentials, query or fragment.'); } + if (!url.pathname.endsWith('/')) url.pathname += '/'; + return { publicMediaUrl: url.href, cmsInternalOrigin: env.CMS_INTERNAL_ORIGIN }; } diff --git a/src/lib/server/media/mapper.ts b/src/lib/server/media/mapper.ts index 0816de95..80d53379 100644 --- a/src/lib/server/media/mapper.ts +++ b/src/lib/server/media/mapper.ts @@ -1,62 +1,26 @@ -import type { components } from '../api/generated/sgfPublicApiSchema'; import type { MediaSourceConfig } from './config'; -import { - MEDIA_PUBLIC_ROUTE_PREFIX, - MEDIA_SOURCE_RELATIVE_PREFIX, - STATIC_MEDIA_PASSTHROUGH, - hasInvalidPercentEscape, - normalizeMediaQuery, - parseMediaKey -} from './validation'; -export type PublicDirectoryMemberDto = components['schemas']['PublicDirectoryMemberDto']; - -export function mapPublicDirectoryMemberMedia( - member: PublicDirectoryMemberDto, - config: MediaSourceConfig -): PublicDirectoryMemberDto { - return { - ...member, - image: mapMediaUrlToSameOrigin(member.image, config) ?? STATIC_MEDIA_PASSTHROUGH - }; -} - -export function mapMediaUrlToSameOrigin(input: string | null | undefined, config: MediaSourceConfig): string | null { - if (!input) return null; - if (input === STATIC_MEDIA_PASSTHROUGH) return input; - if (input.startsWith('//') || input.includes('\\') || hasInvalidPercentEscape(input)) return null; - if (input.includes('#')) return null; - - const publicSourceOrigin = config.publicSourceOrigin; - const cmsInternalOrigin = config.cmsInternalOrigin; - - if (input.startsWith(MEDIA_SOURCE_RELATIVE_PREFIX)) { - return buildSameOriginMediaUrl(input.slice(MEDIA_SOURCE_RELATIVE_PREFIX.length)); - } - - if (input.startsWith(`${publicSourceOrigin}/`)) { - return buildSameOriginMediaUrl(input.slice(publicSourceOrigin.length + 1)); - } - - if (cmsInternalOrigin && input.startsWith(`${cmsInternalOrigin}${MEDIA_SOURCE_RELATIVE_PREFIX}`)) { - return buildSameOriginMediaUrl(input.slice(cmsInternalOrigin.length + MEDIA_SOURCE_RELATIVE_PREFIX.length)); - } +export const STATIC_MEDIA_PASSTHROUGH = '/images/pipey.jpg'; +const unsafe = /[\u0000-\u0020\u007f\\\\]|%(?:0[0-9a-f]|1[0-9a-f]|2e|2f|5c|7f)/i; +// Public CDN paths omit /media/. Native CMS paths include it. +export function publicMediaHref(input: string | null | undefined, config: MediaSourceConfig): string | null { + if (!input || unsafe.test(input) || input.startsWith('//') || /(?:^|\/)\.\.?(?:\/|$)/.test(input)) return null; + try { + const base = new URL(config.publicMediaUrl); + const url = new URL(input, 'https://sgf.dev'); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + if (url.origin === base.origin && url.pathname.startsWith(base.pathname)) return url.href; + if (input.startsWith('/media/') || (url.origin === config.cmsInternalOrigin && url.pathname.startsWith('/media/'))) { + const result = new URL(url.pathname.slice('/media/'.length) + url.search + url.hash, base); + return result.origin === base.origin && result.pathname.startsWith(base.pathname) ? result.href : null; + } + } catch { /* Invalid URLs are omitted from rendered content. */ } return null; } -function buildSameOriginMediaUrl(rawPathAndQuery: string): string | null { - const [rawPath, ...queryParts] = rawPathAndQuery.split('?'); - if (!rawPath || queryParts.length > 1) return null; - - let mediaKey: string; - let mediaQuery: string; - try { - mediaKey = parseMediaKey(rawPath).key; - mediaQuery = normalizeMediaQuery(new URLSearchParams(queryParts[0] ?? '')); - } catch { - return null; - } - - return `${MEDIA_PUBLIC_ROUTE_PREFIX}${mediaKey}${mediaQuery ? `?${mediaQuery}` : ''}`; +export function mapMediaUrlToPublic(input: string | null | undefined, config: MediaSourceConfig): string | null { + if (input === STATIC_MEDIA_PASSTHROUGH) return input; + const href = publicMediaHref(input, config); + return href && /\.(?:avif|gif|jpe?g|png|svg|webp)$/i.test(new URL(href).pathname) ? href : null; } diff --git a/src/lib/server/media/proxy.ts b/src/lib/server/media/proxy.ts deleted file mode 100644 index 6a00c3ef..00000000 --- a/src/lib/server/media/proxy.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { MediaProxyConfig } from './config'; -import { MEDIA_PUBLIC_ROUTE_PREFIX, hasInvalidPercentEscape, normalizeMediaQuery, parseRouteMediaPath } from './validation'; - -export type MediaProxyEvent = { - url: URL; - request: Request; -}; - -export type MediaFetch = (request: Request) => Promise; - -const allowedContentTypes = new Set(['image/jpeg', 'image/png']); -const forbiddenRequestHeaders = ['authorization', 'cookie', 'x-api-key', 'range', 'referer', 'origin', 'forwarded']; -const forbiddenResponseHeaders = ['content-encoding', 'set-cookie', 'www-authenticate']; - -export class MediaProxyError extends Error { - constructor( - readonly status: number, - message = 'Media unavailable' - ) { - super(message); - } -} - -export async function handleMediaProxyRequest( - event: MediaProxyEvent, - method: 'GET' | 'HEAD', - config: MediaProxyConfig, - fetchImpl: MediaFetch = fetch -): Promise { - let parsed; - let query: string; - try { - parsed = parseRouteMediaPath(event.url.pathname, MEDIA_PUBLIC_ROUTE_PREFIX); - assertSearchIsSafe(event.url.search); - query = normalizeMediaQuery(event.url.searchParams); - } catch { - throw new MediaProxyError(400, 'Invalid media request'); - } - - const signal = createLinkedSignal(event.request.signal, config.timeoutMs); - const upstreamRequest = createMediaUpstreamRequest(parsed.key, query, method, config, signal); - assertFinalMediaRequest(upstreamRequest, parsed.key, query, method, config); - - let upstreamResponse: Response; - try { - upstreamResponse = await fetchImpl(upstreamRequest); - } catch (error) { - if (isAbortError(error)) { - throw new MediaProxyError(504); - } - throw new MediaProxyError(502); - } - - return await buildProxyResponse(upstreamResponse, method, query, config); -} - -export function createMediaUpstreamRequest( - mediaKey: string, - query: string, - method: 'GET' | 'HEAD', - config: MediaProxyConfig, - signal?: AbortSignal -): Request { - const url = buildUpstreamUrl(mediaKey, query, config); - return new Request(url, { - method, - cache: 'no-store', - credentials: 'omit', - redirect: 'error', - referrerPolicy: 'no-referrer', - signal, - headers: new Headers() - }); -} - -export function assertFinalMediaRequest( - request: Request, - mediaKey: string, - query: string, - method: 'GET' | 'HEAD', - config: MediaProxyConfig -): void { - const url = new URL(request.url); - if (url.origin !== config.upstreamOrigin) { - throw new Error('Media upstream request escaped the configured origin.'); - } - if (url.pathname !== expectedUpstreamPath(mediaKey, config)) { - throw new Error('Media upstream request escaped the configured path fence.'); - } - if (!url.pathname.startsWith(config.upstreamPathPrefix)) { - throw new Error('Media upstream request escaped the configured media namespace.'); - } - if (url.search !== (query ? `?${query}` : '')) { - throw new Error('Media upstream request changed the approved query.'); - } - if (request.method !== method || !['GET', 'HEAD'].includes(request.method)) { - throw new Error('Media upstream request used an unsupported method.'); - } - if (request.credentials !== 'omit') { - throw new Error('Media upstream request must omit credentials.'); - } - if (request.redirect !== 'error') { - throw new Error('Media upstream request must not follow redirects.'); - } - if (request.referrerPolicy !== 'no-referrer') { - throw new Error('Media upstream request must not send a referrer.'); - } - if (request.cache !== 'no-store') { - throw new Error('Media upstream request must not use a shared fetch cache.'); - } - assertNoForbiddenRequestHeaders(request.headers); -} - -function buildUpstreamUrl(mediaKey: string, query: string, config: MediaProxyConfig): URL { - const url = new URL(config.upstreamOrigin); - url.pathname = expectedUpstreamPath(mediaKey, config); - url.search = query; - return url; -} - -function expectedUpstreamPath(mediaKey: string, config: MediaProxyConfig): string { - return config.upstreamPathPrefix === '/' ? `/${mediaKey}` : `${config.upstreamPathPrefix}${mediaKey}`; -} - -function assertNoForbiddenRequestHeaders(headers: Headers): void { - for (const header of forbiddenRequestHeaders) { - if (headers.has(header)) { - throw new Error('Media upstream request included a forbidden header.'); - } - } - for (const [header] of headers) { - if (header.startsWith('x-forwarded-')) { - throw new Error('Media upstream request included a forwarded header.'); - } - } -} - -async function buildProxyResponse( - upstreamResponse: Response, - method: 'GET' | 'HEAD', - query: string, - config: MediaProxyConfig -): Promise { - let headers: Headers; - try { - if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { - throw new MediaProxyError(502); - } - if (!upstreamResponse.ok) { - throw new MediaProxyError(upstreamResponse.status === 404 ? 404 : 502); - } - assertNoForbiddenResponseHeaders(upstreamResponse.headers); - - const contentType = parseAllowedContentType(upstreamResponse.headers.get('content-type')); - if (!contentType) { - throw new MediaProxyError(502); - } - - const declaredLength = parseContentLength(upstreamResponse.headers.get('content-length')); - if (declaredLength !== undefined && declaredLength > config.maxBytes) { - throw new MediaProxyError(502); - } - - headers = buildSafeResponseHeaders(upstreamResponse.headers, contentType, query, method === 'HEAD' ? declaredLength : undefined); - } catch (error) { - await cancelResponseBodyQuietly(upstreamResponse); - throw error; - } - - if (method === 'HEAD') { - await cancelResponseBodyQuietly(upstreamResponse); - return new Response(null, { status: 200, headers }); - } - - const body = await readBodyUnderLimit(upstreamResponse, config.maxBytes); - const responseBody = new ArrayBuffer(body.byteLength); - new Uint8Array(responseBody).set(body); - headers.set('content-length', String(body.byteLength)); - return new Response(responseBody, { status: 200, headers }); -} - -function assertNoForbiddenResponseHeaders(headers: Headers): void { - for (const header of forbiddenResponseHeaders) { - if (headers.has(header)) { - throw new MediaProxyError(502); - } - } - const cacheControl = headers.get('cache-control'); - if (cacheControl && /(?:^|,)\s*private\b/i.test(cacheControl)) { - throw new MediaProxyError(502); - } -} - -function parseAllowedContentType(value: string | null): string | null { - if (!value) return null; - const type = value.split(';', 1)[0]?.trim().toLowerCase() ?? ''; - return allowedContentTypes.has(type) ? type : null; -} - -function parseContentLength(value: string | null): number | undefined { - if (!value) return undefined; - if (!/^[0-9]+$/.test(value)) { - throw new MediaProxyError(502); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) { - throw new MediaProxyError(502); - } - return parsed; -} - -function buildSafeResponseHeaders( - upstreamHeaders: Headers, - contentType: string, - query: string, - contentLength: number | undefined -): Headers { - const headers = new Headers({ - 'cache-control': queryHasVersionToken(query) ? 'public, max-age=31536000, immutable' : 'public, max-age=3600', - 'content-type': contentType, - 'x-content-type-options': 'nosniff' - }); - if (contentLength !== undefined) { - headers.set('content-length', String(contentLength)); - } - - const etag = sanitizeEtag(upstreamHeaders.get('etag')); - if (etag) { - headers.set('etag', etag); - } - const lastModified = sanitizeLastModified(upstreamHeaders.get('last-modified')); - if (lastModified) { - headers.set('last-modified', lastModified); - } - - return headers; -} - -function sanitizeEtag(value: string | null): string | null { - if (!value) return null; - const etag = value.trim(); - if (etag.length > 128) return null; - return /^(?:W\/)?"[\x21\x23-\x7e]{0,124}"$/.test(etag) ? etag : null; -} - -function sanitizeLastModified(value: string | null): string | null { - if (!value) return null; - const lastModified = value.trim(); - if (!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(lastModified)) { - return null; - } - const parsed = Date.parse(lastModified); - if (!Number.isFinite(parsed)) return null; - const canonical = new Date(parsed).toUTCString(); - return canonical === lastModified ? canonical : null; -} - -async function cancelResponseBodyQuietly(response: Response): Promise { - if (!response.body) return; - try { - await response.body.cancel(); - } catch { - // Cleanup should not mask the response validation error. - } -} - -async function readBodyUnderLimit(response: Response, maxBytes: number): Promise { - if (!response.body) return new Uint8Array(); - - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - - total += value.byteLength; - if (total > maxBytes) { - await reader.cancel(); - throw new MediaProxyError(502); - } - chunks.push(value); - } - } catch (error) { - if (error instanceof MediaProxyError) throw error; - if (isAbortError(error)) throw new MediaProxyError(504); - throw new MediaProxyError(502); - } - - const body = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - body.set(chunk, offset); - offset += chunk.byteLength; - } - return body; -} - -function queryHasVersionToken(query: string): boolean { - return new URLSearchParams(query).has('v'); -} - -function assertSearchIsSafe(search: string): void { - if (hasInvalidPercentEscape(search)) { - throw new Error('Media query contains an invalid percent escape.'); - } -} - -function createLinkedSignal(signal: AbortSignal, timeoutMs: number): AbortSignal { - const timeoutSignal = AbortSignal.timeout(timeoutMs); - return AbortSignal.any([signal, timeoutSignal]); -} - -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError'); -} diff --git a/src/lib/server/media/runtime.ts b/src/lib/server/media/runtime.ts index a543d024..a64fcc4b 100644 --- a/src/lib/server/media/runtime.ts +++ b/src/lib/server/media/runtime.ts @@ -1,18 +1,6 @@ -import { CMS_INTERNAL_ORIGIN, MEDIA_SOURCE_PUBLIC_ORIGIN, MEDIA_UPSTREAM_ORIGIN, MEDIA_UPSTREAM_PATH_PREFIX } from '$app/env/private'; -import { readMediaProxyConfigFromEnv, readMediaSourceConfigFromEnv } from './config'; +import { CMS_INTERNAL_ORIGIN, MEDIA_PUBLIC_URL } from '$app/env/private'; +import { readMediaSourceConfigFromEnv } from './config'; export function readMediaSourceConfig() { - return readMediaSourceConfigFromEnv({ - CMS_INTERNAL_ORIGIN, - MEDIA_SOURCE_PUBLIC_ORIGIN - }); -} - -export function readMediaProxyConfig() { - return readMediaProxyConfigFromEnv({ - CMS_INTERNAL_ORIGIN, - MEDIA_SOURCE_PUBLIC_ORIGIN, - MEDIA_UPSTREAM_ORIGIN, - MEDIA_UPSTREAM_PATH_PREFIX - }); + return readMediaSourceConfigFromEnv({ CMS_INTERNAL_ORIGIN, MEDIA_PUBLIC_URL }); } diff --git a/src/lib/server/media/validation.ts b/src/lib/server/media/validation.ts deleted file mode 100644 index bc8358e3..00000000 --- a/src/lib/server/media/validation.ts +++ /dev/null @@ -1,145 +0,0 @@ -export const MEDIA_PUBLIC_ROUTE_PREFIX = '/media/'; -export const MEDIA_SOURCE_RELATIVE_PREFIX = '/media/'; -export const STATIC_MEDIA_PASSTHROUGH = '/images/pipey.jpg'; - -export const MEDIA_MAX_BYTES_DEFAULT = 8 * 1024 * 1024; -export const MEDIA_TIMEOUT_MS_DEFAULT = 5_000; - -const MAX_MEDIA_KEY_BYTES = 1024; -const MEDIA_KEY_SEGMENTS = 2; -const safeSegmentPattern = /^[A-Za-z0-9._~-]+$/; -const allowedMediaExtensionPattern = /\.(?:jpe?g|png)$/i; -const invalidPercentEscapePattern = /%(?:$|[^0-9A-Fa-f]|.[^0-9A-Fa-f])/; -const encodedDangerPattern = /%(?:00|2e|2f|5c)/i; - -export type ParsedMediaPath = { - key: string; - segments: string[]; -}; - -export function parseRouteMediaPath(pathname: string, publicPrefix = MEDIA_PUBLIC_ROUTE_PREFIX): ParsedMediaPath { - if (!pathname.startsWith(publicPrefix)) { - throw new Error('Media path must use the fixed public media prefix.'); - } - - return parseMediaKey(pathname.slice(publicPrefix.length)); -} - -export function parseMediaKey(rawKey: string): ParsedMediaPath { - if (!rawKey) { - throw new Error('Media key is required.'); - } - if (rawKey.length > MAX_MEDIA_KEY_BYTES) { - throw new Error('Media key is too long.'); - } - if (rawKey.startsWith('/') || rawKey.endsWith('/') || rawKey.includes('//')) { - throw new Error('Media key must not contain empty segments.'); - } - if (rawKey.includes('\\')) { - throw new Error('Media key must not contain backslashes.'); - } - if (hasInvalidPercentEscape(rawKey)) { - throw new Error('Media key contains an invalid percent escape.'); - } - if (encodedDangerPattern.test(rawKey)) { - throw new Error('Media key contains an unsafe encoded character.'); - } - - const rawSegments = rawKey.split('/'); - if (rawSegments.length !== MEDIA_KEY_SEGMENTS) { - throw new Error('Media key must contain exactly two segments.'); - } - - const segments = rawSegments.map((segment) => decodeSafePathSegment(segment)); - if (!allowedMediaExtensionPattern.test(segments[segments.length - 1] ?? '')) { - throw new Error('Media key uses an unsupported file extension.'); - } - return { - key: segments.map((segment) => encodeURIComponent(segment)).join('/'), - segments - }; -} - -export function normalizeMediaQuery(searchParams: URLSearchParams): string { - const normalized = new URLSearchParams(); - const allowed = new Set(['width', 'v']); - - for (const key of searchParams.keys()) { - if (!allowed.has(key)) { - throw new Error('Media query contains an unsupported parameter.'); - } - if (searchParams.getAll(key).length !== 1) { - throw new Error('Media query contains duplicate parameters.'); - } - } - - copyBoundedInteger(searchParams, normalized, 'width', 1, 4096); - copyVersionToken(searchParams, normalized); - - return normalized.toString(); -} - -export function hasInvalidPercentEscape(value: string): boolean { - return invalidPercentEscapePattern.test(value); -} - -export function decodeSafePathSegment(rawSegment: string): string { - if (!rawSegment || rawSegment === '.' || rawSegment === '..') { - throw new Error('Media key contains an unsafe segment.'); - } - - let segment: string; - try { - segment = decodeURIComponent(rawSegment); - } catch { - throw new Error('Media key contains an invalid percent escape.'); - } - - if ( - !segment || - segment === '.' || - segment === '..' || - segment.includes('\0') || - segment.includes('/') || - segment.includes('\\') || - segment.includes('%') || - segment.includes(':') || - segment.includes('@') || - !safeSegmentPattern.test(segment) - ) { - throw new Error('Media key contains an unsafe segment.'); - } - - return segment; -} - -function copyBoundedInteger( - source: URLSearchParams, - target: URLSearchParams, - key: 'width', - minimum: number, - maximum: number -): void { - const value = source.get(key); - if (value === null) return; - if (!/^[0-9]+$/.test(value) || (value.length > 1 && value.startsWith('0'))) { - throw new Error('Media query contains an invalid numeric parameter.'); - } - - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { - throw new Error('Media query contains an out-of-range numeric parameter.'); - } - - target.set(key, String(parsed)); -} - -function copyVersionToken(source: URLSearchParams, target: URLSearchParams): void { - const value = source.get('v'); - if (value === null) return; - if (!/^[A-Za-z0-9_-]{1,64}$/.test(value)) { - throw new Error('Media query contains an invalid cache token.'); - } - - target.set('v', value); -} diff --git a/src/lib/server/member/mapper.ts b/src/lib/server/member/mapper.ts index 6601173b..5d063527 100644 --- a/src/lib/server/member/mapper.ts +++ b/src/lib/server/member/mapper.ts @@ -1,7 +1,7 @@ import type { components } from '../api/generated/sgfPublicApiSchema'; import { isPublicMemberUsername } from '../api/factory'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic } from '../media/mapper'; import { sanitizeMemberBiography } from './biography'; import { safeMemberHttpUrl } from './links'; @@ -45,7 +45,7 @@ export function mapPublicMemberProfile(value: PublicMemberProfileDto, mediaConfi name: string(source.name), url: '/member/' + username, jobTitle: optionalString(source.jobTitle), - image: mapMediaUrlToSameOrigin(string(source.profileImageUrl), mediaConfig) ?? '/images/pipey.jpg', + image: mapMediaUrlToPublic(string(source.profileImageUrl), mediaConfig) ?? '/images/pipey.jpg', tags: array(source.tags).map(string), location: [optionalString(source.city), optionalString(source.state)].filter(Boolean).join(', '), joinMonthLabel: optionalString(source.joinMonthLabel), diff --git a/src/lib/server/pages/html.ts b/src/lib/server/pages/html.ts index 2485a299..32726f6c 100644 --- a/src/lib/server/pages/html.ts +++ b/src/lib/server/pages/html.ts @@ -1,16 +1,12 @@ import sanitizeHtml from 'sanitize-html'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic, publicMediaHref } from '../media/mapper'; const unsafe = /[\u0000-\u0020\u007f\\]|%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i; -const pdfPath = /^\/media\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.~-]+\.pdf)$/i; - -// PDFs remain hrefs on the existing public document origin. They never enter the image proxy. +// Documents link directly to the public media host, just like images. export function publicDocumentHref(path: string, media: MediaSourceConfig): string | null { - if (unsafe.test(path) || !path.startsWith('/media/')) return null; - const url = new URL(path, 'https://sgf.dev'); - const match = pdfPath.exec(url.pathname); - return match ? `${media.publicSourceOrigin}/${match[1]}/${match[2]}${url.search}${url.hash}` : null; + const href = publicMediaHref(path, media); + return href && /\.pdf$/i.test(new URL(href).pathname) ? href : null; } export function safePageHref(raw: string, media: MediaSourceConfig): string | null { @@ -31,7 +27,7 @@ export function safePageHref(raw: string, media: MediaSourceConfig): string | nu const url = new URL(raw); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; if (url.origin === media.cmsInternalOrigin && url.pathname.startsWith('/media/')) { - return publicDocumentHref(`${url.pathname}${url.search}${url.hash}`, media) ?? mapMediaUrlToSameOrigin(raw, media); + return publicDocumentHref(`${url.pathname}${url.search}${url.hash}`, media) ?? mapMediaUrlToPublic(raw, media); } return url.href; } catch { return null; } @@ -52,7 +48,7 @@ export function sanitizePageHtml(html: string, media: MediaSourceConfig): string ...(attrs.target === '_blank' ? { target: '_blank', rel: 'noopener noreferrer' } : {}) } : {} }; }, img: (_tag, attrs): sanitizeHtml.Tag => { - const src = mapMediaUrlToSameOrigin(attrs.src, media); + const src = mapMediaUrlToPublic(attrs.src, media); return { tagName: 'img', attribs: src ? { src, alt: attrs.alt ?? '', loading: 'lazy' } : {} }; } }, diff --git a/src/lib/server/pages/mapper.ts b/src/lib/server/pages/mapper.ts index ad3a1f27..4f8eb188 100644 --- a/src/lib/server/pages/mapper.ts +++ b/src/lib/server/pages/mapper.ts @@ -1,7 +1,7 @@ import { marked } from 'marked'; import type { ContentPagePath } from './paths'; import type { MediaSourceConfig } from '../media/config'; -import { mapMediaUrlToSameOrigin } from '../media/mapper'; +import { mapMediaUrlToPublic } from '../media/mapper'; import { publicDocumentHref, sanitizePageHtml } from './html'; export type ContentPageView = { @@ -78,5 +78,5 @@ function mapOgImage(value: unknown, media: MediaSourceConfig): string | null { if (value == null) return null; if (!Array.isArray(value) || value.length > 1) throw new PageDataError(); if (!value.length) return null; - return mapMediaUrlToSameOrigin(text(record(value[0]).url, 2000), media); + return mapMediaUrlToPublic(text(record(value[0]).url, 2000), media); } diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts index bcf87582..e0be4a06 100644 --- a/src/routes/account/+page.server.ts +++ b/src/routes/account/+page.server.ts @@ -1,7 +1,7 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { memberClient } from '../../lib/server/auth/runtime'; import { profileValues, readProfileForm } from '../../lib/server/auth/profile'; -import { mapMediaUrlToSameOrigin } from '../../lib/server/media/mapper'; +import { mapMediaUrlToPublic } from '../../lib/server/media/mapper'; import { readMediaSourceConfig } from '../../lib/server/media/runtime'; import type { Actions, PageServerLoad } from './$types'; @@ -17,7 +17,7 @@ export const load: PageServerLoad = async event => { values: profileValues(profile.values), skills: profile.skills.map(choice => ({ key: choice.key, name: choice.name })), groups: profile.groups.map(choice => ({ key: choice.key, name: choice.name })), - image: mapMediaUrlToSameOrigin(profile.profileImageUrl ?? '/images/pipey.jpg', readMediaSourceConfig()) ?? '/images/pipey.jpg', + image: mapMediaUrlToPublic(profile.profileImageUrl ?? '/images/pipey.jpg', readMediaSourceConfig()) ?? '/images/pipey.jpg', profileHref: '/member/' + encodeURIComponent(event.locals.member.username), saved: event.url.searchParams.get('saved') === '1', avatarStatus: event.url.searchParams.get('avatar') ?? '' diff --git a/src/routes/account/avatar/+server.ts b/src/routes/account/avatar/+server.ts index 1f4855de..23c54433 100644 --- a/src/routes/account/avatar/+server.ts +++ b/src/routes/account/avatar/+server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import { memberClient } from '../../../lib/server/auth/runtime'; -import { mapMediaUrlToSameOrigin } from '../../../lib/server/media/mapper'; +import { mapMediaUrlToPublic } from '../../../lib/server/media/mapper'; import { readMediaSourceConfig } from '../../../lib/server/media/runtime'; import type { RequestHandler } from './$types'; @@ -24,7 +24,7 @@ export const POST: RequestHandler = async event => { if (result.response.status === 413) failed('size'); if (result.response.status === 429) failed('busy'); if (result.response.status === 400) failed('invalid'); - if (!result.data?.profileImageUrl || !mapMediaUrlToSameOrigin(result.data.profileImageUrl, readMediaSourceConfig())) + if (!result.data?.profileImageUrl || !mapMediaUrlToPublic(result.data.profileImageUrl, readMediaSourceConfig())) failed('unavailable'); // Reload the current member profile; never accept a browser-selected media URL or ID. redirect(303, '/account?avatar=updated'); diff --git a/src/routes/media/[...key]/+server.ts b/src/routes/media/[...key]/+server.ts deleted file mode 100644 index 43494a3e..00000000 --- a/src/routes/media/[...key]/+server.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { PrivateMediaConfigError } from '../../../lib/server/media/config'; -import { handleMediaProxyRequest, MediaProxyError } from '../../../lib/server/media/proxy'; -import { readMediaProxyConfig } from '../../../lib/server/media/runtime'; -import type { RequestHandler } from './$types'; - -export const prerender = false; - -export const GET: RequestHandler = async (event) => mediaResponse(event, 'GET'); -export const HEAD: RequestHandler = async (event) => mediaResponse(event, 'HEAD'); - -async function mediaResponse(event: Parameters[0], method: 'GET' | 'HEAD'): Promise { - try { - return await handleMediaProxyRequest(event, method, readMediaProxyConfig()); - } catch (error) { - if (error instanceof PrivateMediaConfigError) { - return new Response('Media unavailable', { - status: 503, - headers: { - 'cache-control': 'no-store', - 'content-type': 'text/plain; charset=utf-8', - 'x-content-type-options': 'nosniff' - } - }); - } - if (error instanceof MediaProxyError) { - return new Response(error.message, { - status: error.status, - headers: { - 'cache-control': 'no-store', - 'content-type': 'text/plain; charset=utf-8', - 'x-content-type-options': 'nosniff' - } - }); - } - throw error; - } -} diff --git a/tests/companies/companies.test.ts b/tests/companies/companies.test.ts index cffde249..f979bbd6 100644 --- a/tests/companies/companies.test.ts +++ b/tests/companies/companies.test.ts @@ -14,8 +14,8 @@ test('company mapper keeps legacy public fields and real filters, never private const mapped = mapDeliveryCompany(source, companyPath, media); assert.equal(mapped.path, companyPath); assert.equal(mapped.name, source.name); assert.equal(mapped.headline, source.properties.headline); - assert.equal(mapped.image, '/media/synthetic/logo.png'); - assert.equal(mapped.featuredImage, '/media/synthetic/featured.png'); + assert.equal(mapped.image, 'https://media.example.test/synthetic/logo.png'); + assert.equal(mapped.featuredImage, 'https://media.example.test/synthetic/featured.png'); assert.equal(mapped.isFoundingSponsor, true); assert.equal(mapped.location, 'Springfield, MO'); assert.equal(mapped.skills[0].url, '/directory/?skills=' + skillTerm); assert.equal(mapGroup(groupFixture(), media).skills[0].url, mapped.skills[0].url); diff --git a/tests/directory/directory-load.test.ts b/tests/directory/directory-load.test.ts index a230f147..e3116ef2 100644 --- a/tests/directory/directory-load.test.ts +++ b/tests/directory/directory-load.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { DirectoryLoadError, loadDirectory, parseDirectorySkillQuery } from '../../src/lib/server/directory/load'; const mediaConfig = { - publicSourceOrigin: 'https://media.sgf.dev', + publicMediaUrl: 'https://media.sgf.dev/', cmsInternalOrigin: 'http://cms.test' }; @@ -90,7 +90,7 @@ describe('directory loading', () => { assert.equal(result.directory.visibleMembers, 2); assert.equal(result.directory.filters[0]?.active, false); assert.equal(result.directory.filters[1]?.active, false); - assert.equal(result.directory.members[0]?.image, '/media/members/ada.jpg?width=500'); + assert.equal(result.directory.members[0]?.image, 'https://media.sgf.dev/members/ada.jpg?width=500'); assert.equal(result.directory.members[1]?.image, '/images/pipey.jpg'); assert.deepEqual( requests.map((request) => new URL(request.url).pathname + new URL(request.url).search), diff --git a/tests/groups/fixture.ts b/tests/groups/fixture.ts index cbb5f5d9..4d672765 100644 --- a/tests/groups/fixture.ts +++ b/tests/groups/fixture.ts @@ -1,6 +1,6 @@ import type { PublicGroupDto } from '../../src/lib/server/groups/mapper'; -export const media = { publicSourceOrigin: 'https://media.example.test', cmsInternalOrigin: 'http://cms.example.test' }; +export const media = { publicMediaUrl: 'https://media.example.test/', cmsInternalOrigin: 'http://cms.example.test' }; export function groupFixture(name = 'Z synthetic group', path = '/groups/Rust-SGF/'): PublicGroupDto { return { name, path, aboutHtml: '

Existing group about text unchanged.

', diff --git a/tests/groups/groups.test.ts b/tests/groups/groups.test.ts index e7644586..a494cb1c 100644 --- a/tests/groups/groups.test.ts +++ b/tests/groups/groups.test.ts @@ -43,7 +43,7 @@ test('explicit mapping keeps dates/order/case, safe leaders, skills and sanitize aboutHtml: '

Text good

' }; const mapped = mapGroup(source, media); assert.equal(mapped.name, source.name); assert.equal(mapped.path, '/groups/Rust-SGF/'); - assert.equal(mapped.image, '/media/synthetic/group.jpg'); + assert.equal(mapped.image, 'https://media.example.test/synthetic/group.jpg'); assert.equal(mapped.establishedText, source.establishedText); assert.equal(mapped.skills[0].url, '/directory/?skills=11111111-2222-3333-4444-555555555555'); assert.equal(mapped.leaders[0].path, '/member/Jane'); diff --git a/tests/home/home-load.test.ts b/tests/home/home-load.test.ts index bcb6afc8..836a6b8c 100644 --- a/tests/home/home-load.test.ts +++ b/tests/home/home-load.test.ts @@ -6,7 +6,7 @@ import type { MediaSourceConfig } from '../../src/lib/server/media/config'; const cmsOrigin = 'http://127.0.0.1:5099'; const mediaConfig: MediaSourceConfig = { - publicSourceOrigin: 'https://media.sgf.dev', + publicMediaUrl: 'https://media.sgf.dev/', cmsInternalOrigin: cmsOrigin }; diff --git a/tests/home/home-mapper.test.ts b/tests/home/home-mapper.test.ts index 150202b1..3b04ab84 100644 --- a/tests/home/home-mapper.test.ts +++ b/tests/home/home-mapper.test.ts @@ -4,7 +4,7 @@ import { mapPublicHomeDto, PublicHomeDataError, type PublicHomeDto } from '../.. import type { MediaSourceConfig } from '../../src/lib/server/media/config'; const mediaConfig: MediaSourceConfig = { - publicSourceOrigin: 'https://media.sgf.dev', + publicMediaUrl: 'https://media.sgf.dev/', cmsInternalOrigin: 'http://127.0.0.1:5099' }; @@ -84,13 +84,13 @@ describe('home DTO mapper', () => { assert.equal(view.sponsors[0]?.path, '/companies/fictional-sponsor/'); }); - it('rewrites approved media fields to same-origin media URLs and uses fallbacks for unsupported images', () => { + it('rewrites approved media fields to public media URLs and uses fallbacks for unsupported images', () => { const view = mapPublicHomeDto(homeDto(), mediaConfig); - assert.equal(view.nextDevNight?.presentations[0]?.presenters[0]?.imageUrl, '/media/people/casey.jpg?width=500&v=abc123'); - assert.equal(view.nextDevNight?.presentations[0]?.presenters[1]?.imageUrl, '/images/pipey.jpg'); - assert.equal(view.directory.dailyMembers[0]?.image, '/media/members/riley.png?width=500&v=v1'); - assert.equal(view.sponsors[0]?.logoUrl, '/media/sponsors/logo.png?v=logo1'); + assert.equal(view.nextDevNight?.presentations[0]?.presenters[0]?.imageUrl, 'https://media.sgf.dev/people/casey.jpg?width=500&v=abc123'); + assert.equal(view.nextDevNight?.presentations[0]?.presenters[1]?.imageUrl, 'https://media.sgf.dev/people/morgan.svg'); + assert.equal(view.directory.dailyMembers[0]?.image, 'https://media.sgf.dev/members/riley.png?width=500&v=v1'); + assert.equal(view.sponsors[0]?.logoUrl, 'https://media.sgf.dev/sponsors/logo.png?v=logo1'); }); it('drops invalid external links and does not leak paired labels', () => { diff --git a/tests/jobs/fixture.ts b/tests/jobs/fixture.ts index 66218137..2d8d55a8 100644 --- a/tests/jobs/fixture.ts +++ b/tests/jobs/fixture.ts @@ -1,5 +1,5 @@ import type { PublicJobDto } from '../../src/lib/server/jobs/mapper'; -export const media = { publicSourceOrigin: 'https://media.example.test', cmsInternalOrigin: 'http://127.0.0.1:5099' }; +export const media = { publicMediaUrl: 'https://media.example.test/', cmsInternalOrigin: 'http://127.0.0.1:5099' }; export function jobFixture(): PublicJobDto { return { name: 'Z synthetic engineer', companyName: 'Synthetic company', path: '/companies/element-11/mid-level-engineer/', location: 'Springfield, MO', employmentType: 'Full time', compensation: '$90,000', posted: 'January 2, 2001', diff --git a/tests/leadership/fixture.ts b/tests/leadership/fixture.ts index ff5d38f5..3be5d934 100644 --- a/tests/leadership/fixture.ts +++ b/tests/leadership/fixture.ts @@ -1,5 +1,5 @@ import type { PublicLeadershipDto } from '../../src/lib/server/leadership/mapper'; -export const media = { publicSourceOrigin: 'https://media.example.test', cmsInternalOrigin: 'http://127.0.0.1:5099' }; +export const media = { publicMediaUrl: 'https://media.example.test/', cmsInternalOrigin: 'http://127.0.0.1:5099' }; export function leadershipFixture(): PublicLeadershipDto { const first = { name: 'Z synthetic officer', username: 'Ada123', imageUrl: '/media/synthetic/officer.png', officerTitle: 'President', officerBio: '

Public officer biography.

' }; diff --git a/tests/leadership/leadership.test.ts b/tests/leadership/leadership.test.ts index c0dc1952..2744e335 100644 --- a/tests/leadership/leadership.test.ts +++ b/tests/leadership/leadership.test.ts @@ -41,7 +41,7 @@ test('mapper preserves selection and history order, public names/titles, safe pr assert.deepEqual(mapped.boardOfDirectors.map(m => m.name), ['A synthetic officer', 'Z synthetic officer']); assert.deepEqual(mapped.history.map(m => m.name), ['Z synthetic officer', 'A synthetic officer', 'Z synthetic officer']); assert.equal(mapped.officers[0].path, '/member/Ada123'); assert.equal(mapped.officers[0].officerTitle, 'President'); - assert.equal(mapped.officers[0].image, '/media/synthetic/officer.png'); + assert.equal(mapped.officers[0].image, 'https://media.example.test/synthetic/officer.png'); assert.match(mapped.officers[0].officerBio, /officer<\/strong>/); assert.equal(mapped.history[1].officerTitle || 'Board Member', 'Board Member'); assert.ok(mapped.boardOfDirectors.every(m => m.officerBio === '' && m.officerTitle === null)); diff --git a/tests/media/media-proxy.test.ts b/tests/media/media-proxy.test.ts deleted file mode 100644 index c616d787..00000000 --- a/tests/media/media-proxy.test.ts +++ /dev/null @@ -1,474 +0,0 @@ -import { createServer, type Server } from 'node:http'; -import { once } from 'node:events'; -import { existsSync, readFileSync } from 'node:fs'; -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { PrivateMediaConfigError, parseOrigin, parseUpstreamPathPrefix, type MediaProxyConfig } from '../../src/lib/server/media/config'; -import { mapMediaUrlToSameOrigin, mapPublicDirectoryMemberMedia } from '../../src/lib/server/media/mapper'; -import { - MediaProxyError, - assertFinalMediaRequest, - createMediaUpstreamRequest, - handleMediaProxyRequest, - type MediaFetch -} from '../../src/lib/server/media/proxy'; -import { normalizeMediaQuery, parseMediaKey, parseRouteMediaPath } from '../../src/lib/server/media/validation'; - -const pngBytes = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lZs8fAAAAABJRU5ErkJggg==', - 'base64' -); - -function config(overrides: Partial = {}): MediaProxyConfig { - return { - publicSourceOrigin: 'https://media.sgf.dev', - cmsInternalOrigin: 'http://127.0.0.1:5099', - upstreamOrigin: 'http://127.0.0.1:5100', - upstreamPathPrefix: '/media/', - maxBytes: 8 * 1024 * 1024, - timeoutMs: 5_000, - ...overrides - }; -} - -function eventFor(pathAndQuery: string, method: 'GET' | 'HEAD' = 'GET') { - const url = new URL(pathAndQuery, 'http://localhost'); - return { - url, - request: new Request(url, { method }) - }; -} - -async function responseFor( - pathAndQuery: string, - fetchImpl: MediaFetch, - overrides: Partial = {}, - method: 'GET' | 'HEAD' = 'GET' -) { - return await handleMediaProxyRequest(eventFor(pathAndQuery, method), method, config(overrides), fetchImpl); -} - -function imageResponse(body: BodyInit | null = pngBytes, init: ResponseInit = {}) { - return new Response(body, { - status: init.status ?? 200, - headers: { 'content-type': 'image/png', ...init.headers } - }); -} - -function cancellableImageResponse(init: ResponseInit = {}) { - let cancelled = false; - const body = new ReadableStream({ - start(controller) { - controller.enqueue(pngBytes); - }, - cancel() { - cancelled = true; - } - }); - - return { - response: imageResponse(body, init), - wasCancelled: () => cancelled - }; -} - -describe('media URL mapper', () => { - it('maps public CDN root and raw CMS media URLs to same-origin media paths', () => { - assert.equal( - mapMediaUrlToSameOrigin('https://media.sgf.dev/2ohcee3f/tiffany.jpg?width=500&v=1dab6ba4e54822f', config()), - '/media/2ohcee3f/tiffany.jpg?width=500&v=1dab6ba4e54822f' - ); - assert.equal( - mapMediaUrlToSameOrigin('/media/elzl3w1n/1538675380692.jpg?width=500&v=abc', config()), - '/media/elzl3w1n/1538675380692.jpg?width=500&v=abc' - ); - assert.equal( - mapMediaUrlToSameOrigin('http://127.0.0.1:5099/media/elzl3w1n/1538675380692.jpg', config()), - '/media/elzl3w1n/1538675380692.jpg' - ); - }); - - it('keeps only the exact static fallback passthrough', () => { - assert.equal(mapMediaUrlToSameOrigin('/images/pipey.jpg', config()), '/images/pipey.jpg'); - assert.equal(mapMediaUrlToSameOrigin('/images/logo.svg', config()), null); - assert.equal(mapMediaUrlToSameOrigin('/images/pipey.jpg?width=500', config()), null); - }); - - it('rejects unsafe DTO source strings before URL normalization can hide them', () => { - const invalid = [ - 'https://evil.example/x.jpg', - 'https://www.sgf.dev/media/x.jpg', - '//media.sgf.dev/x.jpg', - 'https://user:pass@media.sgf.dev/x.jpg', - '/media/https://evil.example/x.jpg', - '/media/a/../secret.jpg', - '/media/a/%2e%2e/secret.jpg', - '/media/a%2fb.jpg', - '/media/a%252fb.jpg', - '/media/a%5cb.jpg', - '/media/a%00.jpg', - '/media/a//b.jpg', - '/media/./b.jpg', - '/media/a.jpg?url=https://evil.example/x.jpg', - '/media/a.jpg?width=999999', - '/media/a.jpg?width=500&width=800', - '/media/a.jpg#fragment', - '/media/folder/file.svg', - '/media/folder/file.gif', - '/media/folder/file.webp', - '/media/folder/file.pdf', - '/media/folder/file' - ]; - - for (const value of invalid) { - assert.equal(mapMediaUrlToSameOrigin(value, config()), null, value); - } - }); - - it('maps the generated directory member image field and leaves other fields alone', () => { - const member = { - name: 'Ada', - location: 'Springfield', - image: 'https://media.sgf.dev/a/ada.png?width=500&v=abc', - url: '/members/ada', - tags: ['Svelte'] - }; - - assert.deepEqual(mapPublicDirectoryMemberMedia(member, config()), { - ...member, - image: '/media/a/ada.png?width=500&v=abc' - }); - assert.equal(mapPublicDirectoryMemberMedia({ ...member, image: 'https://evil.example/ada.png' }, config()).image, '/images/pipey.jpg'); - assert.equal(mapPublicDirectoryMemberMedia({ ...member, image: 'https://media.sgf.dev/a/ada.svg' }, config()).image, '/images/pipey.jpg'); - }); -}); - -describe('media path, query, and configuration validation', () => { - it('accepts fixture-backed raster extensions and observed query parameters', () => { - assert.equal(parseMediaKey('folder/photo.jpg').key, 'folder/photo.jpg'); - assert.equal(parseMediaKey('folder/photo.jpeg').key, 'folder/photo.jpeg'); - assert.equal(parseMediaKey('folder/logo.png').key, 'folder/logo.png'); - assert.equal(normalizeMediaQuery(new URLSearchParams('width=500&v=1dab6ba4e54822f')), 'width=500&v=1dab6ba4e54822f'); - }); - - it('rejects the threat-matrix path examples', () => { - const invalid = [ - '', - '/absolute.jpg', - 'a//b.jpg', - 'a/./b.jpg', - 'a/../b.jpg', - 'a/%2e%2e/b.jpg', - 'a%2fb.jpg', - 'a%252fb.jpg', - 'a%5cb.jpg', - 'a\\b.jpg', - 'a%00.jpg', - 'http:/evil.example/x.jpg', - 'https:/evil.example/x.jpg', - 'user:pass@host/x.jpg', - 'one-segment.jpg', - 'folder/.hidden/too/many/path/segments/for/this/proxy/image.jpg' - ]; - - for (const value of invalid) { - assert.throws(() => parseMediaKey(value), value); - } - }); - - it('rejects unknown, duplicate, escaped, and out-of-range query parameters', () => { - const invalid = [ - 'url=https://evil.example/x.jpg', - 'width=0', - 'width=4097', - 'width=0500', - 'width=500&width=800', - 'height=250', - 'quality=75', - 'format=png', - 'format=svg', - 'format=pdf', - 'v=', - 'v=a'.repeat(70), - 'rmode=crop' - ]; - - for (const query of invalid) { - assert.throws(() => normalizeMediaQuery(new URLSearchParams(query)), query); - } - }); - - it('requires a specific local/CMS/S3 path fence and allows root only for the dedicated media origin', () => { - assert.equal(parseOrigin('https://media.sgf.dev/', 'MEDIA_SOURCE_PUBLIC_ORIGIN'), 'https://media.sgf.dev'); - assert.equal(parseUpstreamPathPrefix(undefined, 'https://media.sgf.dev'), '/'); - assert.equal(parseUpstreamPathPrefix('/media/', 'http://127.0.0.1:5099'), '/media/'); - assert.equal(parseUpstreamPathPrefix('/bootstrap-bucket/media/', 'http://127.0.0.1:8333'), '/bootstrap-bucket/media/'); - assert.throws(() => parseUpstreamPathPrefix(undefined, 'http://127.0.0.1:5099')); - assert.throws(() => parseUpstreamPathPrefix('/', 'http://127.0.0.1:5099')); - assert.throws(() => parseUpstreamPathPrefix('/', 'https://cms.sgf.dev')); - assert.throws(() => parseUpstreamPathPrefix('/', 'https://media.sgf.dev:8443'), PrivateMediaConfigError); - assert.throws(() => parseOrigin('not-a-url', 'MEDIA_UPSTREAM_ORIGIN'), PrivateMediaConfigError); - assert.throws(() => parseOrigin('https://media.sgf.dev/private', 'MEDIA_UPSTREAM_ORIGIN'), PrivateMediaConfigError); - assert.throws(() => parseUpstreamPathPrefix('/media/%2e%2e/', 'http://127.0.0.1:5099'), PrivateMediaConfigError); - }); -}); - -describe('media proxy route handler', () => { - it('builds and checks the final upstream request immediately before fetch', () => { - const upstreamRequest = createMediaUpstreamRequest('folder/photo.jpg', 'width=500&v=abc', 'GET', config()); - - assert.equal(upstreamRequest.url, 'http://127.0.0.1:5100/media/folder/photo.jpg?width=500&v=abc'); - assert.equal(upstreamRequest.method, 'GET'); - assert.equal(upstreamRequest.credentials, 'omit'); - assert.equal(upstreamRequest.redirect, 'error'); - assert.equal(upstreamRequest.referrerPolicy, 'no-referrer'); - assert.equal(upstreamRequest.cache, 'no-store'); - assertFinalMediaRequest(upstreamRequest, 'folder/photo.jpg', 'width=500&v=abc', 'GET', config()); - assert.equal(upstreamRequest.headers.has('cookie'), false); - assert.equal(upstreamRequest.headers.has('authorization'), false); - assert.equal(upstreamRequest.headers.has('range'), false); - }); - - it('does not call the network for invalid paths or queries', async () => { - let calls = 0; - const fetchImpl: MediaFetch = async () => { - calls += 1; - return imageResponse(); - }; - const invalid = [ - '/media/a%2fb.jpg', - '/media/a%252fb.jpg', - '/media/a%5cb.jpg', - '/media/a%00.jpg', - '/media/http:/evil.example/x.jpg', - '/media/folder/photo.jpg?url=https://evil.example/x.jpg', - '/media/folder/photo.jpg?width=500&width=800', - '/media/folder/photo.jpg?width=999999', - '/media/folder/photo.jpg?format=svg', - '/media/folder/photo.gif' - ]; - - for (const path of invalid) { - await assert.rejects(responseFor(path, fetchImpl), MediaProxyError, path); - } - assert.equal(calls, 0); - }); - - it('fetches valid media with no caller cookies or request headers', async () => { - const requests: Request[] = []; - const url = new URL('/media/folder/photo.png?width=500&v=abc', 'http://localhost'); - const event = { - url, - request: new Request(url, { - headers: { - cookie: 'session=secret', - authorization: 'Bearer secret', - range: 'bytes=0-1', - referer: 'https://cms.sgf.dev/private' - } - }) - }; - - const response = await handleMediaProxyRequest( - event, - 'GET', - config(), - async (request) => { - requests.push(request); - return imageResponse(pngBytes); - } - ); - - assert.equal(response.status, 200); - assert.equal(requests.length, 1); - for (const header of ['cookie', 'authorization', 'range', 'referer', 'origin', 'forwarded', 'x-forwarded-for']) { - assert.equal(requests[0]?.headers.has(header), false, header); - } - assert.equal(requests[0]?.credentials, 'omit'); - assert.equal(requests[0]?.redirect, 'error'); - assert.equal(requests[0]?.referrerPolicy, 'no-referrer'); - assert.equal(requests[0]?.cache, 'no-store'); - }); - - it('filters response headers and keeps GET and HEAD body behavior distinct', async () => { - const upstreamHeaders = { - 'content-type': 'image/png; charset=binary', - 'content-length': String(pngBytes.byteLength), - 'cache-control': 'public, max-age=10', - server: 'upstream', - 'cf-ray': 'secret', - 'x-storage-secret': 'hidden', - etag: '"abc"', - 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT' - }; - const fetchImpl: MediaFetch = async () => imageResponse(pngBytes, { headers: upstreamHeaders }); - const get = await responseFor('/media/folder/photo.png?width=500&v=abc', fetchImpl); - const head = await responseFor('/media/folder/photo.png?width=500&v=abc', fetchImpl, {}, 'HEAD'); - - assert.equal(get.headers.get('content-type'), 'image/png'); - assert.equal(get.headers.get('cache-control'), 'public, max-age=31536000, immutable'); - assert.equal(get.headers.get('x-content-type-options'), 'nosniff'); - assert.equal(get.headers.get('etag'), '"abc"'); - assert.equal(get.headers.get('last-modified'), 'Wed, 21 Oct 2015 07:28:00 GMT'); - assert.equal(get.headers.get('server'), null); - assert.equal(get.headers.get('cf-ray'), null); - assert.equal(get.headers.get('x-storage-secret'), null); - assert.deepEqual(new Uint8Array(await get.arrayBuffer()), new Uint8Array(pngBytes)); - assert.equal(head.status, 200); - assert.equal(head.headers.get('content-length'), String(pngBytes.byteLength)); - assert.equal((await head.text()).length, 0); - }); - - it('sanitizes optional upstream validators before forwarding them', async () => { - const strong = await responseFor('/media/folder/photo.png', async () => - imageResponse(pngBytes, { headers: { etag: '"abc"', 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT' } }) - ); - assert.equal(strong.headers.get('etag'), '"abc"'); - assert.equal(strong.headers.get('last-modified'), 'Wed, 21 Oct 2015 07:28:00 GMT'); - - const weak = await responseFor('/media/folder/photo.png', async () => imageResponse(pngBytes, { headers: { etag: 'W/"abc"' } })); - assert.equal(weak.headers.get('etag'), 'W/"abc"'); - - const malformed = await responseFor('/media/folder/photo.png', async () => - imageResponse(pngBytes, { - headers: { - etag: 'bare:opaque/value', - 'last-modified': 'Mon Jan 01 2001 00:00:00 GMT+0000 (internal host)' - } - }) - ); - assert.equal(malformed.headers.get('etag'), null); - assert.equal(malformed.headers.get('last-modified'), null); - }); - - it('rejects forbidden upstream response types, redirects, errors, private headers, and body-cap violations', async () => { - await assert.rejects(responseFor('/media/folder/photo.png', async () => new Response('', { headers: { 'content-type': 'text/html' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => new Response(pngBytes, { headers: { 'content-type': 'image/gif' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(null, { status: 302, headers: { location: 'https://evil.example/x' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse('missing', { status: 404 })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(pngBytes, { headers: { 'set-cookie': 'secret=1' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(pngBytes, { headers: { 'content-encoding': 'gzip' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(pngBytes, { headers: { 'cache-control': 'private' } })), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(pngBytes, { headers: { 'content-length': '9' } }), { maxBytes: 8 }), MediaProxyError); - await assert.rejects(responseFor('/media/folder/photo.png', async () => imageResponse(Buffer.alloc(9)), { maxBytes: 8 }), MediaProxyError); - }); - - it('cancels upstream bodies on early rejection and unused HEAD bodies', async () => { - const cases: Array<{ name: string; response: Response; overrides?: Partial; method?: 'GET' | 'HEAD'; wasCancelled: () => boolean }> = []; - - for (const [name, init, overrides] of [ - ['unsupported MIME', { headers: { 'content-type': 'text/html' } }, undefined], - ['forbidden header', { headers: { 'set-cookie': 'secret=1' } }, undefined], - ['private cache', { headers: { 'cache-control': 'private' } }, undefined], - ['invalid declared length', { headers: { 'content-length': 'abc' } }, undefined], - ['oversized declared length', { headers: { 'content-length': '9' } }, { maxBytes: 8 }], - ['non-success', { status: 500 }, undefined] - ] as const) { - const tracked = cancellableImageResponse(init); - cases.push({ name, response: tracked.response, overrides, wasCancelled: tracked.wasCancelled }); - } - - for (const rejected of cases) { - await assert.rejects(responseFor('/media/folder/photo.png', async () => rejected.response, rejected.overrides), MediaProxyError, rejected.name); - assert.equal(rejected.wasCancelled(), true, rejected.name); - } - - const head = cancellableImageResponse({ headers: { 'content-length': String(pngBytes.byteLength) } }); - const response = await responseFor('/media/folder/photo.png', async () => head.response, {}, 'HEAD'); - assert.equal(response.status, 200); - assert.equal(head.wasCancelled(), true); - }); - - it('links caller aborts to the upstream request signal', async () => { - const controller = new AbortController(); - const url = new URL('/media/folder/photo.png', 'http://localhost'); - const seen: AbortSignal[] = []; - const pending = handleMediaProxyRequest( - { url, request: new Request(url, { signal: controller.signal }) }, - 'GET', - config(), - async (request) => { - seen.push(request.signal); - controller.abort(); - throw new DOMException('aborted', 'AbortError'); - } - ); - - await assert.rejects(pending, MediaProxyError); - assert.equal(seen.length, 1); - assert.equal(seen[0]?.aborted, true); - }); - - it('maps upstream timeout aborts to a generic gateway timeout', async () => { - await assert.rejects( - responseFor('/media/folder/photo.png', async () => { - throw new DOMException('timed out', 'TimeoutError'); - }), - (error) => error instanceof MediaProxyError && error.status === 504 - ); - }); - - it('serves a synthetic local image through the configured namespace', async () => { - const { server, origin } = await startImageServer(); - try { - const response = await responseFor( - '/media/folder/synthetic.png?width=500&v=abc', - fetch, - { upstreamOrigin: origin, upstreamPathPrefix: '/media/' } - ); - assert.equal(response.status, 200); - assert.equal(response.headers.get('content-type'), 'image/png'); - assert.deepEqual(new Uint8Array(await response.arrayBuffer()), new Uint8Array(pngBytes)); - } finally { - server.close(); - } - }); -}); - -describe('media proxy source hygiene', () => { - it('keeps private media env imports in server-only modules', () => { - const route = readFileSync('src/routes/media/[...key]/+server.ts', 'utf8'); - const runtimeSource = readFileSync('src/lib/server/media/runtime.ts', 'utf8'); - const configSource = readFileSync('src/lib/server/media/config.ts', 'utf8'); - const mapperSource = readFileSync('src/lib/server/media/mapper.ts', 'utf8'); - - assert.match(runtimeSource, /\$app\/env\/private/); - assert.doesNotMatch(runtimeSource, /\$app\/env\/public/); - assert.doesNotMatch(configSource, /\$app\/env\/private/); - assert.doesNotMatch(route, /MEDIA_UPSTREAM_ORIGIN/); - assert.doesNotMatch(mapperSource, /HomeDto|Presenter|Sponsor/); - }); - - it('fixture media currently uses raster images with width and v only when the coordinator fixture is present', () => { - const fixturePath = '/tmp/sgf-public-parity-fixtures/fixtures/home.public.fixture.json'; - const fixture = existsSync(fixturePath) - ? readFileSync(fixturePath, 'utf8') - : JSON.stringify([ - 'https://media.sgf.dev/2ohcee3f/tiffany.jpg?width=500&v=1dab6ba4e54822f', - 'https://media.sgf.dev/hr4mbdax/untitled_2023-04-13_03-52-54.png' - ]); - const matches = [...fixture.matchAll(/https:\/\/media\.sgf\.dev\/[^" ]+/g)].map((match) => match[0]); - assert.ok(matches.length > 0); - assert.ok(matches.every((url) => /\.(?:jpg|jpeg|png)(?:\?|$)/i.test(url)), 'fixture should not need SVG/PDF in this layer'); - assert.ok(matches.every((url) => !url.includes('?') || /^width=\d+&v=[A-Za-z0-9_-]+$/.test(url.split('?')[1] ?? ''))); - }); -}); - -async function startImageServer(): Promise<{ server: Server; origin: string }> { - const server = createServer((request, response) => { - assert.equal(request.headers.cookie, undefined); - assert.equal(request.headers.authorization, undefined); - assert.equal(request.headers.range, undefined); - assert.equal(request.url, '/media/folder/synthetic.png?width=500&v=abc'); - response.writeHead(200, { - 'content-type': 'image/png', - 'content-length': String(pngBytes.byteLength) - }); - response.end(pngBytes); - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - assert.ok(address && typeof address === 'object'); - return { server, origin: `http://127.0.0.1:${address.port}` }; -} diff --git a/tests/media/public-media.test.ts b/tests/media/public-media.test.ts new file mode 100644 index 00000000..5c47bd19 --- /dev/null +++ b/tests/media/public-media.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mapMediaUrlToPublic, publicMediaHref } from '../../src/lib/server/media/mapper'; +import { readMediaSourceConfigFromEnv } from '../../src/lib/server/media/config'; + +const config = { publicMediaUrl: 'https://media.example.test/', cmsInternalOrigin: 'http://cms.internal' }; + +test('preserves CDN URLs and maps native CMS media without losing image parameters', () => { + const image = 'https://media.example.test/logos/sponsor.svg?width=500&height=300&mode=crop'; + assert.equal(mapMediaUrlToPublic(image, config), image); + assert.equal(mapMediaUrlToPublic('http://cms.internal/media/people/ada.webp?v=2', config), 'https://media.example.test/people/ada.webp?v=2'); + assert.equal(publicMediaHref('/media/docs/bylaws.pdf#page=2', config), 'https://media.example.test/docs/bylaws.pdf#page=2'); +}); + +test('supports a local public CMS media base without using the internal API address', () => { + const local = readMediaSourceConfigFromEnv({ MEDIA_PUBLIC_URL: 'http://localhost:5099/media', CMS_INTERNAL_ORIGIN: 'http://cms.internal' }); + assert.equal(mapMediaUrlToPublic('/media/people/ada.png', local), 'http://localhost:5099/media/people/ada.png'); +}); + +test('omits unsafe images and unapproved hosts', () => { + assert.equal(mapMediaUrlToPublic('javascript:alert(1)', config), null); + assert.equal(mapMediaUrlToPublic('https://other.example.test/tracker.png', config), null); + assert.equal(mapMediaUrlToPublic('/media/../private.png', config), null); + assert.equal(mapMediaUrlToPublic('/media/docs/bylaws.pdf', config), null); +}); diff --git a/tests/member/fixture.ts b/tests/member/fixture.ts index 7ea7e707..79f2f68a 100644 --- a/tests/member/fixture.ts +++ b/tests/member/fixture.ts @@ -1,6 +1,6 @@ import type { PublicMemberProfileDto } from '../../src/lib/server/member/mapper'; -export const mediaConfig = { publicSourceOrigin: 'https://media.sgf.dev', cmsInternalOrigin: 'http://127.0.0.1:5099' }; +export const mediaConfig = { publicMediaUrl: 'https://media.sgf.dev/', cmsInternalOrigin: 'http://127.0.0.1:5099' }; export const profile: PublicMemberProfileDto = { username: 'Ada123', name: 'Ada Lovelace', firstName: 'Ada', lastName: 'Lovelace', jobTitle: 'Software Developer', profileImageUrl: '/media/synthetic/profile.png?width=800', diff --git a/tests/member/mapper.test.ts b/tests/member/mapper.test.ts index d71d754b..98996d78 100644 --- a/tests/member/mapper.test.ts +++ b/tests/member/mapper.test.ts @@ -15,7 +15,7 @@ test('member mapping preserves public text/order/date labels and whitelists ever assert.equal(member.location, 'Springfield, MO'); assert.equal(member.joinMonthLabel, 'December 2020'); assert.deepEqual(member.tags, profile.tags); - assert.equal(member.image, '/media/synthetic/profile.png?width=800'); + assert.equal(member.image, 'https://media.sgf.dev/synthetic/profile.png?width=800'); assert.deepEqual(member.skills, [{ name: 'JavaScript', url: '/directory/?skills=17d3e25e-bd1f-4a4c-98ef-89ad894d4f7d' }]); assert.deepEqual(member.socials.map((social) => social.label), ['example.test', 'Twitter', 'LinkedIn', 'Facebook', 'Instagram', 'Youtube']); assert.ok(member.available); @@ -23,14 +23,14 @@ test('member mapping preserves public text/order/date labels and whitelists ever for (const social of member.socials) assert.deepEqual(Object.keys(social), ['label', 'url']); }); -test('only approved proxy images and safe HTTP(S) links reach member PageData', () => { +test('only approved public images and safe HTTP(S) links reach member PageData', () => { for (const url of ['javascript:evil()', 'data:x', '//evil.test', 'https://user:pass@evil.test', 'https://evil.test/%0d', 'https://evil.test/\u0001', 'https:\\evil.test']) { const result = mapPublicMemberProfile({ ...profile, websiteUrl: url, twitterUrl: url, profileImageUrl: url }, mediaConfig); assert.equal(result.websiteUrl, null); assert.ok(!result.socials.some((social) => social.label === 'Twitter')); assert.equal(result.image, '/images/pipey.jpg'); } - assert.equal(mapPublicMemberProfile({ ...profile, profileImageUrl: 'https://media.sgf.dev/synthetic/profile.png' }, mediaConfig).image, '/media/synthetic/profile.png'); + assert.equal(mapPublicMemberProfile({ ...profile, profileImageUrl: 'https://media.sgf.dev/synthetic/profile.png' }, mediaConfig).image, 'https://media.sgf.dev/synthetic/profile.png'); }); test('member mapper rejects malformed fields instead of fictional success data', () => { diff --git a/tests/pages/fixture.ts b/tests/pages/fixture.ts index 0e05039e..7c15d803 100644 --- a/tests/pages/fixture.ts +++ b/tests/pages/fixture.ts @@ -1,6 +1,6 @@ import type { ContentPagePath } from '../../src/lib/server/pages/paths'; -export const media = { publicSourceOrigin: 'https://media.example.test', cmsInternalOrigin: 'http://cms.example.test' }; +export const media = { publicMediaUrl: 'https://media.example.test/', cmsInternalOrigin: 'http://cms.example.test' }; export function deliveryPage(path: ContentPagePath = '/about/code-of-conduct/') { return { contentType: 'page', name: 'Code of Conduct', id: '00000000-0000-0000-0000-000000000001', diff --git a/tests/pages/legacy-pages.test.ts b/tests/pages/legacy-pages.test.ts index 0edf9c14..849da200 100644 --- a/tests/pages/legacy-pages.test.ts +++ b/tests/pages/legacy-pages.test.ts @@ -43,7 +43,7 @@ test('generic legacy blocks keep formatting and links; empty search page adds no assert.match(mapped.blocks[0].html, /

Synthetic party<\/h2>/); assert.match(mapped.blocks[0].html, /Join us\.<\/strong>/); assert.match(mapped.blocks[0].html, /href="https:\/\/forms.gle\/example"/); - assert.match(mapped.blocks[0].html, /src="\/media\/synthetic\/party.png"/); + assert.match(mapped.blocks[0].html, /src="https:\/\/media\.example\.test\/synthetic\/party.png"/); assert.doesNotMatch(JSON.stringify(mapped), /bad\(\)|iframe|private-/); const search = mapDeliveryPage({ ...deliveryPage('/search-results/'), name: 'Search Results', diff --git a/tests/pages/pages.test.ts b/tests/pages/pages.test.ts index 4fc57722..16f8360e 100644 --- a/tests/pages/pages.test.ts +++ b/tests/pages/pages.test.ts @@ -68,7 +68,7 @@ test('known metadata, ordered markdown/native rich-text blocks and document link const mapped = mapDeliveryPage(deliveryPage(), options.path, media); assert.equal(mapped.title, 'Conduct | Springfield Devs'); assert.equal(mapped.description, 'Our community standards.'); - assert.equal(mapped.ogImage, '/media/synthetic/banner.jpg'); + assert.equal(mapped.ogImage, 'https://media.example.test/synthetic/banner.jpg'); assert.match(mapped.blocks[0].html, /

Welcome<\/h2>/); assert.match(mapped.blocks[0].html, /kind<\/strong>/); assert.match(mapped.blocks[0].html, /href="\/register"/); @@ -99,12 +99,12 @@ test('sanitizer preserves content and approved image mapping, strips scripts, ha const html = sanitizePageHtml('

Heading

Text bad good

Photo', media); assert.match(html, /

Heading<\/h2>/); assert.match(html, /

Text/); assert.match(html, /rel="noopener noreferrer"/); - assert.match(html, /src="\/media\/test\/photo.png" alt="Photo" loading="lazy"/); + assert.match(html, /src="https:\/\/media\.example\.test\/test\/photo.png" alt="Photo" loading="lazy"/); for (const marker of ['bad()', 'onclick', 'onerror', 'style=', 'script', 'iframe', 'svg', 'data:', 'evil.test']) assert.ok(!html.includes(marker)); assert.equal((html.match(/ { +test('safe links and PDF downloads use the public media host', () => { for (const raw of ['javascript:alert(1)', 'data:text/html,x', '//evil.test', '/%2fexample.test', '/a\\b', '/%0aevil', 'https://user:pass@example.test/']) assert.equal(safePageHref(raw, media), null); assert.equal(safePageHref('/register', media), '/register'); assert.equal(safePageHref('#sponsors', media), '#sponsors');