diff --git a/AGENTS.md b/AGENTS.md index 125b4c01..a3afe170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,11 +44,15 @@ website/ ```bash npm run dev # Development server npm run build # Production build -npm run generate # Static site generation npm run eslint # Lint with auto-fix npm run prettier # Format code ``` +There is deliberately no static-generation script. See +[the upgrade plan](docs/dependency-upgrade-plan.md) for why: a static build has to prerender every +`` variant, which is ~6200 downloads and resizes against the CMS, and it does not finish. +Nothing deployed used it — Vercel runs `nuxt build` and serves images through `_vercel/image`. + ### Directus CMS (run from `directus-cms/`) ```bash diff --git a/nuxt-app/components/IndividualPlatforms.vue b/nuxt-app/components/IndividualPlatforms.vue index 0137c7ca..c7f483d1 100644 --- a/nuxt-app/components/IndividualPlatforms.vue +++ b/nuxt-app/components/IndividualPlatforms.vue @@ -49,7 +49,7 @@ import { OPEN_SPEAKER_LINKEDIN_EVENT_ID, OPEN_SPEAKER_BLUESKY_EVENT_ID, } from '../config'; -import { trackGoal } from '../helpers' +import { normalizeExternalUrl, trackGoal } from '../helpers' import { computed } from 'vue'; type Scope = 'speaker' | 'member' @@ -112,49 +112,49 @@ const platformList = computed(() => { { name: 'Bluesky', icon: BLUESKY_ICON, - url: props.platforms.bluesky_url, + url: normalizeExternalUrl(props.platforms.bluesky_url, 'bluesky'), eventId: BLUESKY_EVENT_ID, }, { name: 'Mastodon', icon: MastodonIcon, - url: props.platforms.mastodon_url, + url: normalizeExternalUrl(props.platforms.mastodon_url, 'mastodon'), eventId: MASTODON_EVENT_ID, }, { name: 'LinkedIn', icon: LINKEDIN_ICON, - url: props.platforms.linkedin_url, + url: normalizeExternalUrl(props.platforms.linkedin_url, 'linkedin'), eventId: LINKEDIN_EVENT_ID, }, { name: 'Instagram', icon: INSTAGRAM_ICON, - url: props.platforms.instagram_url, + url: normalizeExternalUrl(props.platforms.instagram_url, 'instagram'), eventId: INSTAGRAM_EVENT_ID, }, { name: 'GitHub', icon: GITHUB_ICON, - url: props.platforms.github_url, + url: normalizeExternalUrl(props.platforms.github_url, 'github'), eventId: GITHUB_EVENT_ID, }, { name: 'YouTube', icon: YOUTUBE_ICON, - url: props.platforms.youtube_url, + url: normalizeExternalUrl(props.platforms.youtube_url, 'youtube'), eventId: YOUTUBE_EVENT_ID, }, { name: 'Website', icon: WEBSITE_ICON, - url: props.platforms.website_url, + url: normalizeExternalUrl(props.platforms.website_url, 'web'), eventId: WEBSITE_EVENT_ID, }, { name: 'Twitter', icon: TWITTER_ICON, - url: props.platforms.twitter_url, + url: normalizeExternalUrl(props.platforms.twitter_url, 'twitter'), eventId: TWITTER_EVENT_ID, }, ].filter((platform) => platform.url) as { diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index 33ad9b63..f8a87bd2 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -34,6 +34,16 @@ import { anonymizeAndHashIP } from './../helpers/' const collectionWithTagsName = ['members', 'speakers', 'podcasts', 'meetups', 'picks_of_the_day'] as const type CollectionWithTagsName = (typeof collectionWithTagsName)[number] export type Tag = { name: string; count: number } + +/** + * Reduces an error to the part that is safe to print in a browser console. + * + * The SDK rejects with a `RequestError` carrying the raw `Response` and the request that produced it, + * so logging the object itself can put request details in front of the user. + */ +function toLogMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} type DirectusTag = { tag: { id: string; name: string } } export function useDirectus() { @@ -894,25 +904,23 @@ export function useDirectus() { async function getCurrentUser() { try { - console.log('Refreshing auth', await directus.refresh()) - - const user = await directus.with(rest({ credentials: 'include' })).request(readMe()) - console.log('Current user', user) - - // Maybe add some persistence etc. here? - return user - } catch (e: unknown) { - console.log('Error while reading current user', e) + // Do not log these. `refresh()` resolves to the SDK's auth payload, whose fields include + // `access_token` and `refresh_token`, and `readMe()` to the account's email and role — + // and both of these calls run in the browser. + await directus.refresh() + + return await directus.with(rest({ credentials: 'include' })).request(readMe()) + } catch (error: unknown) { + console.error('Error while reading current user:', toLogMessage(error)) } } async function registerNewUser(email: string, password: string) { try { - const result = await directus.request(createUser({ email, password })) - console.log(result) - } catch (e: unknown) { - console.error('Error while registering new user', e) - return e + await directus.request(createUser({ email, password })) + } catch (error: unknown) { + console.error('Error while registering new user:', toLogMessage(error)) + return error } } diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts index f408a8fc..68f63389 100644 --- a/nuxt-app/helpers/index.ts +++ b/nuxt-app/helpers/index.ts @@ -3,6 +3,7 @@ export * from './formatAudioTimestamp' export * from './getCookie' export * from './getHashCode' export * from './getMetaInfo' +export * from './normalizeExternalUrl' export * from './getTrimmedString' export * from './parseCmsDate' export * from './resolveNewsLink' diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts new file mode 100644 index 00000000..50398e0f --- /dev/null +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -0,0 +1,126 @@ +/** + * The kind of link a CMS field is meant to hold. + * + * Anything other than `web` can also accept a bare handle, because that is what editors tend to type + * into a social field. Knowing the platform is what makes a handle resolvable: `t.muelleer` is a + * perfectly good Instagram username but reads like a hostname, and only the field it came from can + * settle which was meant. + */ +export type ExternalUrlKind = + 'web' | 'twitter' | 'instagram' | 'linkedin' | 'github' | 'youtube' | 'bluesky' | 'mastodon' + +interface PlatformRules { + /** Hosts that mean "this is already a URL, just missing its scheme". */ + hosts: string[] + /** Builds a profile URL from a bare handle, or returns undefined if it cannot. */ + profileUrl: ((handle: string) => string | undefined) | null +} + +const PLATFORMS: Record = { + // A website field has no handle form — a value with no host is simply unusable. + web: { hosts: [], profileUrl: null }, + twitter: { + hosts: ['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com'], + profileUrl: (handle) => `https://twitter.com/${handle}`, + }, + instagram: { + hosts: ['instagram.com', 'www.instagram.com'], + profileUrl: (handle) => `https://www.instagram.com/${handle}`, + }, + linkedin: { + // Personal profiles live under /in/. Company pages use /company/ and are always pasted as a + // full URL, so they take the absolute path through this function. + hosts: ['linkedin.com', 'www.linkedin.com'], + profileUrl: (handle) => `https://www.linkedin.com/in/${handle}`, + }, + github: { + hosts: ['github.com', 'www.github.com'], + profileUrl: (handle) => `https://github.com/${handle}`, + }, + youtube: { + // YouTube handles are @-prefixed in URLs, unlike the others. + hosts: ['youtube.com', 'www.youtube.com', 'youtu.be'], + profileUrl: (handle) => `https://www.youtube.com/@${handle}`, + }, + bluesky: { + // Bluesky handles are themselves domains (`someone.bsky.social`), so a dot means nothing here. + hosts: ['bsky.app', 'www.bsky.app'], + profileUrl: (handle) => `https://bsky.app/profile/${handle}`, + }, + mastodon: { + // Federated, so the instance is part of the handle: `@user@instance.social`. + hosts: [], + profileUrl: (handle) => { + const [user, instance] = handle.split('@') + return user && instance ? `https://${instance}/@${user}` : undefined + }, + }, +} + +/** + * Makes a CMS-entered link safe to put in an `href`, or returns `undefined` if it cannot be. + * + * Editors leave the scheme off — `www.linkedin.com/in/someone` rather than + * `https://www.linkedin.com/in/someone`. A browser resolves that against the current page, so the link + * lands on `/hall-of-fame/www.linkedin.com/in/someone` and shows the visitor a 404 on our own domain + * instead of taking them to the profile. + * + * They also paste bare handles into social fields. With `kind` set, those become real profile URLs; + * with `kind` left at `web`, a value that cannot be a host is dropped rather than guessed at. + * + * Whatever comes back is absolute, root-relative, or `undefined` — never something a browser would + * resolve against the current page. Callers should omit the link entirely for `undefined`. + * + * @param url The raw value from the CMS. + * @param kind Which field it came from. Defaults to `web`. + * + * @returns A URL safe to use as an `href`, or `undefined`. + */ +export function normalizeExternalUrl( + url: string | null | undefined, + kind: ExternalUrlKind = 'web' +): string | undefined { + const trimmed = url?.trim() + + if (!trimmed) { + return undefined + } + + // The only schemes worth putting in an href from a CMS field. + if (/^(https?|mailto|tel):/i.test(trimmed)) { + return trimmed + } + + // Any other scheme — `javascript:`, `vbscript:`, `data:` — is dropped rather than passed through. + // This also drops a host with an explicit port (`example.com:8080/x`), which is indistinguishable + // from a scheme here; dropping the link is the safe side of that ambiguity, and no CMS value has + // one. Written as two tests rather than a capture group because `match()[1]` is + // `string | undefined` under `noUncheckedIndexedAccess`. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + return undefined + } + + // Protocol-relative, or deliberately site-internal. + if (trimmed.startsWith('//') || trimmed.startsWith('/')) { + return trimmed + } + + const rules = PLATFORMS[kind] + const host = (trimmed.split(/[/?#]/)[0] ?? '').toLowerCase() + + // A URL missing only its scheme: either it names one of the platform's own hosts, or it has a path + // after something host-shaped, which a handle never does. + if (rules.hosts.includes(host) || (host.includes('.') && trimmed.includes('/'))) { + return `https://${trimmed}` + } + + if (rules.profileUrl) { + // The `@` is how people write handles; it is not part of the URL for any platform except + // Mastodon, where it separates user from instance, and YouTube, which re-adds its own. + const handle = kind === 'mastodon' ? trimmed.replace(/^@/, '') : trimmed.replace(/^@+/, '') + return handle ? rules.profileUrl(handle) : undefined + } + + // `web`: no handle form, so the value has to stand up as a host on its own. + return host.includes('.') ? `https://${trimmed}` : undefined +} diff --git a/nuxt-app/helpers/sanitize.ts b/nuxt-app/helpers/sanitize.ts index b8d52a08..7868ab41 100644 --- a/nuxt-app/helpers/sanitize.ts +++ b/nuxt-app/helpers/sanitize.ts @@ -1,4 +1,5 @@ import DOMPurify from 'isomorphic-dompurify' +import { normalizeExternalUrl } from './normalizeExternalUrl' // The single place that decides how untrusted CMS text is made safe to render. Every `v-html` binding // and every plain-text excerpt in the app goes through one of these, so a policy change — tightening @@ -8,6 +9,46 @@ import DOMPurify from 'isomorphic-dompurify' // pulling isomorphic-dompurify in through it would instantiate jsdom for consumers that only wanted a // date helper. Import this module directly. +// Editors also leave the scheme off links *inside* rich text, not just in the dedicated URL fields: +// `href="innoq.com/de/staff/stefan-tilkov/"` in a speaker biography resolves against the current page +// and 404s on our own domain, and the prerender crawler follows it and fails the build. +// +// This runs in `afterSanitizeAttributes`, so DOMPurify has already removed anything unsafe — a +// `javascript:` href is gone before this sees the node, and only values it approved are rewritten. +// Root-relative links are left alone, so internal links in rich text keep working. +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (typeof (node as Element).getAttribute !== 'function') { + return + } + + const element = node as Element + + for (const attribute of ['href', 'src']) { + const value = element.getAttribute(attribute) + + if (!value) { + continue + } + + // Only fill in a *missing* scheme. Anything that already has one has been through DOMPurify's + // own URI policy, which allows things a CMS text field should not have to justify — an inline + // `data:` image being the obvious one. Second-guessing it here would strip those. + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) { + continue + } + + const normalized = normalizeExternalUrl(value) + + if (normalized === undefined) { + // Not usable as a URL at all — better a non-clickable link than one that navigates into + // our own site and 404s. + element.removeAttribute(attribute) + } else if (normalized !== value) { + element.setAttribute(attribute, normalized) + } + } +}) + /** * Sanitises CMS rich text for rendering as HTML. * diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index 26b78e88..12beed9c 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -112,6 +112,20 @@ export default defineNuxtConfig({ return } + // Keep image URLs out of the prerender crawl. Following them makes the crawler resize + // every `` variant it finds — 1476 files and ~100 MB from 44 routes — and + // exhaust connections to the CMS, which fails the build via `failOnError`. + // + // Keep the `static` guard even though there is no `generate` script any more: `npx nuxi + // generate` still works, sets `nitro.static`, and produces no server. There the crawler's + // output *is* what serves these URLs, so skipping it would silently 404 every optimised + // image instead of failing loudly. + if (!nitroConfig.static) { + nitroConfig.prerender ??= {} + nitroConfig.prerender.ignore ??= [] + nitroConfig.prerender.ignore.push('/_ipx') + } + // Lets CI build without the CMS being reachable. Skips route discovery only — the // bundle is still built in full, and deploys never set it. if (process.env.SKIP_PRERENDER_ROUTE_DISCOVERY === 'true') { diff --git a/nuxt-app/package.json b/nuxt-app/package.json index c76bda28..b72e81b8 100644 --- a/nuxt-app/package.json +++ b/nuxt-app/package.json @@ -8,7 +8,6 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", - "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", "eslint": "eslint --fix .", diff --git a/nuxt-app/pages/login-callback.vue b/nuxt-app/pages/login-callback.vue index 5c0be794..b54efab8 100644 --- a/nuxt-app/pages/login-callback.vue +++ b/nuxt-app/pages/login-callback.vue @@ -42,8 +42,6 @@ const loginPage: ComputedRef = computed(() => pag onMounted(async () => { clientSideUser.value = await directus.getCurrentUser() - - console.log('User', clientSideUser.value) }) // Set loading screen diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts new file mode 100644 index 00000000..a9ca4923 --- /dev/null +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest' +import { normalizeExternalUrl, type ExternalUrlKind } from '../helpers/normalizeExternalUrl' + +// The cases named after real CMS records are the reason this exists. A schemeless value in an `href` +// is resolved against the current page, so `www.linkedin.com/in/x` became +// `/hall-of-fame/www.linkedin.com/in/x` — a 404 on our own domain instead of the profile. + +const ALL_KINDS: ExternalUrlKind[] = [ + 'web', + 'twitter', + 'instagram', + 'linkedin', + 'github', + 'youtube', + 'bluesky', + 'mastodon', +] + +describe('normalizeExternalUrl', () => { + describe('regardless of kind', () => { + it('leaves an absolute URL alone', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl('https://www.linkedin.com/in/claudiaplattner', kind)).toBe( + 'https://www.linkedin.com/in/claudiaplattner' + ) + expect(normalizeExternalUrl('http://example.com', kind)).toBe('http://example.com') + expect(normalizeExternalUrl('mailto:hallo@programmier.bar', kind)).toBe('mailto:hallo@programmier.bar') + expect(normalizeExternalUrl('//cdn.example.com/x', kind)).toBe('//cdn.example.com/x') + } + }) + + it('drops dangerous schemes even when they are absolute', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl('javascript:alert(1)', kind)).toBeUndefined() + expect(normalizeExternalUrl('JaVaScRiPt:alert(1)', kind)).toBeUndefined() + expect(normalizeExternalUrl('vbscript:msgbox(1)', kind)).toBeUndefined() + } + }) + + it('drops a host with an explicit port, which cannot be told apart from a scheme', () => { + // Documented limitation rather than an accident: dropping is the safe side of the + // ambiguity, and no CMS value currently has a port. + expect(normalizeExternalUrl('example.com:8080/path')).toBeUndefined() + }) + + it('adds the missing scheme to a schemeless URL', () => { + // Both were live in the CMS and 404'd on our own domain. + expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/', 'linkedin')).toBe( + 'https://www.linkedin.com/in/mariekilg/' + ) + expect(normalizeExternalUrl('twitter.com/devpg', 'twitter')).toBe('https://twitter.com/devpg') + }) + + it('handles absent and whitespace-only input', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl(undefined, kind)).toBeUndefined() + expect(normalizeExternalUrl(null, kind)).toBeUndefined() + expect(normalizeExternalUrl('', kind)).toBeUndefined() + expect(normalizeExternalUrl(' ', kind)).toBeUndefined() + } + }) + + it('trims before deciding', () => { + expect(normalizeExternalUrl(' twitter.com/devpg ', 'twitter')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl(' @jSchaback ', 'twitter')).toBe('https://twitter.com/jSchaback') + }) + + it('never returns something a browser resolves against the current page', () => { + // The property that actually matters. + const inputs = ['www.linkedin.com/in/x/', 'twitter.com/devpg', 't.muelleer', '@jSchaback', 'plainname', ''] + for (const kind of ALL_KINDS) { + for (const input of inputs) { + const out = normalizeExternalUrl(input, kind) + if (out !== undefined) { + expect(out, `${kind} / ${JSON.stringify(input)}`).toMatch(/^([a-z][a-z0-9+.-]*:|\/)/i) + } + } + } + }) + }) + + describe("kind 'web' (the default)", () => { + it('accepts a bare host', () => { + expect(normalizeExternalUrl('example.com')).toBe('https://example.com') + expect(normalizeExternalUrl('sub.example.co.uk/path?a=1#b')).toBe('https://sub.example.co.uk/path?a=1#b') + }) + + it('drops a value that cannot be a host, because a website field has no handle form', () => { + expect(normalizeExternalUrl('justausername')).toBeUndefined() + expect(normalizeExternalUrl('@someone')).toBeUndefined() + }) + + it('is what you get when kind is omitted', () => { + expect(normalizeExternalUrl('example.com')).toBe(normalizeExternalUrl('example.com', 'web')) + }) + }) + + describe('social handles', () => { + it('resolves the two handles that are actually in the CMS', () => { + // johannes-schaback | twitter_url = "@jSchaback" + expect(normalizeExternalUrl('@jSchaback', 'twitter')).toBe('https://twitter.com/jSchaback') + // tobias-m-mueller | instagram_url = "t.muelleer" — a dot is legal in an Instagram username, + // which is exactly why the kind is needed to tell it apart from a hostname. + expect(normalizeExternalUrl('t.muelleer', 'instagram')).toBe('https://www.instagram.com/t.muelleer') + }) + + it('builds profile URLs per platform', () => { + expect(normalizeExternalUrl('devpg', 'twitter')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl('someone', 'instagram')).toBe('https://www.instagram.com/someone') + expect(normalizeExternalUrl('mariekilg', 'linkedin')).toBe('https://www.linkedin.com/in/mariekilg') + expect(normalizeExternalUrl('octocat', 'github')).toBe('https://github.com/octocat') + expect(normalizeExternalUrl('programmierbar', 'youtube')).toBe('https://www.youtube.com/@programmierbar') + }) + + it('strips a leading @ where the platform does not use one', () => { + expect(normalizeExternalUrl('@octocat', 'github')).toBe('https://github.com/octocat') + expect(normalizeExternalUrl('@someone', 'instagram')).toBe('https://www.instagram.com/someone') + }) + + it('re-adds the @ that YouTube handles require', () => { + expect(normalizeExternalUrl('@programmierbar', 'youtube')).toBe('https://www.youtube.com/@programmierbar') + }) + + it('treats a Bluesky handle as a handle, not a host', () => { + // Bluesky handles are domains, so the dot heuristic used for `web` would be wrong here. + expect(normalizeExternalUrl('programmier.bar', 'bluesky')).toBe('https://bsky.app/profile/programmier.bar') + expect(normalizeExternalUrl('someone.bsky.social', 'bluesky')).toBe( + 'https://bsky.app/profile/someone.bsky.social' + ) + }) + + it('splits a Mastodon handle into instance and user', () => { + expect(normalizeExternalUrl('@podcast@social.programmier.bar', 'mastodon')).toBe( + 'https://social.programmier.bar/@podcast' + ) + }) + + it('drops a Mastodon handle with no instance, since the server cannot be guessed', () => { + expect(normalizeExternalUrl('@podcast', 'mastodon')).toBeUndefined() + expect(normalizeExternalUrl('podcast', 'mastodon')).toBeUndefined() + }) + + it("recognises the platform's own host rather than treating it as a handle", () => { + expect(normalizeExternalUrl('x.com/devpg', 'twitter')).toBe('https://x.com/devpg') + expect(normalizeExternalUrl('youtu.be/abc123', 'youtube')).toBe('https://youtu.be/abc123') + expect(normalizeExternalUrl('bsky.app/profile/someone', 'bluesky')).toBe('https://bsky.app/profile/someone') + }) + }) +}) diff --git a/nuxt-app/test/sanitize.test.ts b/nuxt-app/test/sanitize.test.ts index 091fb38d..be52b8b7 100644 --- a/nuxt-app/test/sanitize.test.ts +++ b/nuxt-app/test/sanitize.test.ts @@ -112,3 +112,48 @@ describe('getPlainText', () => { expect(getPlainText('
sichtbar
')).toBe('sichtbar') }) }) + +describe('links inside rich text', () => { + it('absolutises a schemeless href, which would otherwise 404 on our own domain', () => { + // Real CMS value, in speaker stefan-tilkov's biography. As `href="innoq.com/..."` a browser + // resolves it against the current page: /hall-of-fame/innoq.com/de/staff/stefan-tilkov/. + expect(sanitizeHtml('

Profil

')).toBe( + '

Profil

' + ) + }) + + it('leaves absolute and root-relative links alone', () => { + expect(sanitizeHtml('x')).toBe('x') + expect(sanitizeHtml('intern')).toBe('intern') + expect(sanitizeHtml('mail')).toBe( + 'mail' + ) + }) + + it('drops an href that cannot be a URL rather than leaving it relative', () => { + expect(sanitizeHtml('x')).toBe('x') + }) + + it('still removes unsafe URLs — the hook must not reintroduce them', () => { + for (const payload of [ + 'x', + 'x', + 'x', + ]) { + expect(dangerousTagsIn(sanitizeHtml(payload))).toEqual([]) + expect(sanitizeHtml(payload)).not.toMatch(/javascript:/i) + } + }) + + it('leaves an inline data: image alone, deferring to DOMPurify on existing schemes', () => { + // The hook only fills in a missing scheme. DOMPurify permits `data:` images, and vetting + // schemes here as well would strip them. + const dataUri = + '' + expect(sanitizeHtml(dataUri)).toContain('data:image/png;base64,') + }) + + it('normalises src as well as href', () => { + expect(sanitizeHtml('')).toBe('') + }) +})