Skip to content
Merged
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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<nuxt-img>` 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
Expand Down
18 changes: 9 additions & 9 deletions nuxt-app/components/IndividualPlatforms.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 22 additions & 14 deletions nuxt-app/composables/useDirectus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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))
}
Comment thread
Jan0707 marked this conversation as resolved.
}

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
}
Comment thread
Jan0707 marked this conversation as resolved.
}

Expand Down
1 change: 1 addition & 0 deletions nuxt-app/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
126 changes: 126 additions & 0 deletions nuxt-app/helpers/normalizeExternalUrl.ts
Original file line number Diff line number Diff line change
@@ -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<ExternalUrlKind, PlatformRules> = {
// 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
}
41 changes: 41 additions & 0 deletions nuxt-app/helpers/sanitize.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*
Expand Down
14 changes: 14 additions & 0 deletions nuxt-app/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,20 @@ export default defineNuxtConfig({
return
}

// Keep image URLs out of the prerender crawl. Following them makes the crawler resize
// every `<nuxt-img>` 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') {
Expand Down
1 change: 0 additions & 1 deletion nuxt-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"eslint": "eslint --fix .",
Expand Down
2 changes: 0 additions & 2 deletions nuxt-app/pages/login-callback.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ const loginPage: ComputedRef<DirectusLoginPage | undefined> = computed(() => pag

onMounted(async () => {
clientSideUser.value = await directus.getCurrentUser()

console.log('User', clientSideUser.value)
})

// Set loading screen
Expand Down
Loading
Loading