Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/content-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/leadership-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 0 additions & 25 deletions docs/media-proxy.md

This file was deleted.

17 changes: 17 additions & 0 deletions docs/media.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 2 additions & 10 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/server/companies/mapper.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
Expand Down
5 changes: 2 additions & 3 deletions src/lib/server/directory/mapper.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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`)
};
Expand Down
6 changes: 3 additions & 3 deletions src/lib/server/groups/mapper.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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)
};
}),
Expand Down
9 changes: 4 additions & 5 deletions src/lib/server/home/mapper.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand Down Expand Up @@ -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`)
};
Expand Down Expand Up @@ -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`)
};
Expand All @@ -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`)
Expand Down
4 changes: 2 additions & 2 deletions src/lib/server/leadership/mapper.ts
Original file line number Diff line number Diff line change
@@ -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'];
Expand All @@ -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) : ''
};
Expand Down
Loading
Loading