diff --git a/.env.worker.prod.example b/.env.worker.prod.example index 6ccc49a..22a35e2 100644 --- a/.env.worker.prod.example +++ b/.env.worker.prod.example @@ -6,6 +6,9 @@ PUBLIC_WEATHER_MQTT_HOST=https://wx.swansea-airport.wales/mqtt DIRECTUS_URL=https://cms.swansea-airport.wales DIRECTUS_PUBLIC_URL=https://cms.swansea-airport.wales +# Optional for Worker test/prod builds. Leave unset locally to use Directus /assets URLs. +# DIRECTUS_ASSET_BASE_URL=https://assets.swansea-airport.wales/ +# DIRECTUS_ASSET_URL_TEMPLATE=https://assets.swansea-airport.wales/{id} DIRECTUS_ADMIN_TOKEN=replace-with-production-directus-token DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners diff --git a/.env.worker.test.example b/.env.worker.test.example index 04ee7d4..67e80ed 100644 --- a/.env.worker.test.example +++ b/.env.worker.test.example @@ -6,6 +6,9 @@ PUBLIC_WEATHER_MQTT_HOST=https://wx.swansea-airport.wales/mqtt DIRECTUS_URL=https://egfhcmstest.pattinson.org DIRECTUS_PUBLIC_URL=https://egfhcmstest.pattinson.org +# Optional for Worker test/prod builds. Leave unset locally to use Directus /assets URLs. +# DIRECTUS_ASSET_BASE_URL=https://assets-test.example.com/ +# DIRECTUS_ASSET_URL_TEMPLATE=https://assets-test.example.com/{id} DIRECTUS_ADMIN_TOKEN=replace-with-test-directus-token DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners diff --git a/docs/cloudflare-worker.md b/docs/cloudflare-worker.md index 540dd1f..31c6fde 100644 --- a/docs/cloudflare-worker.md +++ b/docs/cloudflare-worker.md @@ -52,6 +52,30 @@ CF_WORKER_NAME=swansea-airfield-test For Directus, use public HTTPS URLs in Worker env files. Do not use Docker-only hostnames such as `http://directus:8055` outside Docker Compose. +## Directus Assets From R2 + +Local development can keep using Directus asset URLs. For test and production Worker builds, set one of these optional values in `.env.worker.test` and `.env.worker.prod` to make the generated Astro HTML point at R2-hosted files instead: + +```env +DIRECTUS_ASSET_BASE_URL=https://assets.swansea-airport.wales/ +``` + +With `DIRECTUS_ASSET_BASE_URL`, a Directus file ID such as `abc-123` becomes: + +```text +https://assets.swansea-airport.wales/abc-123.jpeg +``` + +The extension comes from Directus `filename_download`, so R2 object keys should use the pattern `.`. + +If the R2 public URL needs a custom path shape, use a template instead: + +```env +DIRECTUS_ASSET_URL_TEMPLATE=https://assets.swansea-airport.wales/directus/{id} +``` + +`DIRECTUS_ASSET_URL_TEMPLATE` takes priority over `DIRECTUS_ASSET_BASE_URL`. The template supports `{id}`, `{key}`, and `{fileId}` as aliases for the R2 object key, usually Directus `filename_disk`. Leave both unset in local `.env` to keep using `DIRECTUS_PUBLIC_URL/assets/`. + ## Build Locally For Test When working through Docker Compose: diff --git a/src/lib/directus.ts b/src/lib/directus.ts index 155f3b5..f1681d7 100644 --- a/src/lib/directus.ts +++ b/src/lib/directus.ts @@ -35,6 +35,13 @@ const directusUrl = process.env.DIRECTUS_URL ?? 'http://directus:8055'; const directusPublicUrl = process.env.DIRECTUS_PUBLIC_URL && !process.env.DIRECTUS_PUBLIC_URL.includes('example.com') ? process.env.DIRECTUS_PUBLIC_URL : directusUrl; +const directusAssetBaseUrl = process.env.DIRECTUS_ASSET_BASE_URL && !process.env.DIRECTUS_ASSET_BASE_URL.includes('example.com') + ? process.env.DIRECTUS_ASSET_BASE_URL + : undefined; +const directusAssetUrlTemplate = + process.env.DIRECTUS_ASSET_URL_TEMPLATE && !process.env.DIRECTUS_ASSET_URL_TEMPLATE.includes('example.com') + ? process.env.DIRECTUS_ASSET_URL_TEMPLATE + : undefined; const directusToken = process.env.DIRECTUS_ADMIN_TOKEN; const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners'; const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page'; @@ -49,6 +56,7 @@ type DirectusFile = { title?: string; description?: string; filename_download?: string; + filename_disk?: string; type?: string; }; @@ -57,8 +65,8 @@ type EventTemplateRecord = { title?: string; slug?: string; description?: string; - image?: string; - logo?: string; + image?: string | DirectusFile; + logo?: string | DirectusFile; booking_url?: string; }; @@ -113,7 +121,46 @@ async function readDirectusEndpoint(endpoint: URL): Promise { return payload.data ?? []; } -export function resolveDirectusAssetUrl(fileId: string): string { +function extensionFromFilename(filename?: string): string { + if (!filename) return ''; + + const lastSegment = filename.split('/').pop() ?? ''; + const dotIndex = lastSegment.lastIndexOf('.'); + + if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) return ''; + return lastSegment.slice(dotIndex); +} + +function directusFileId(file: string | DirectusFile): string { + return typeof file === 'string' ? file : file.id; +} + +function directusObjectKey(file: string | DirectusFile): string { + if (typeof file !== 'string' && file.filename_disk) { + return file.filename_disk; + } + + const fileId = directusFileId(file); + return `${fileId}${extensionFromFilename(typeof file === 'string' ? undefined : file.filename_download)}`; +} + +export function resolveDirectusAssetUrl(file: string | DirectusFile): string { + const fileId = directusFileId(file); + const r2ObjectKey = directusObjectKey(file); + const encodedObjectKey = encodeURIComponent(r2ObjectKey); + + if (directusAssetUrlTemplate) { + return directusAssetUrlTemplate + .replaceAll('{fileId}', encodedObjectKey) + .replaceAll('{id}', encodedObjectKey) + .replaceAll('{key}', encodedObjectKey); + } + + if (directusAssetBaseUrl) { + const baseUrl = directusAssetBaseUrl.endsWith('/') ? directusAssetBaseUrl : `${directusAssetBaseUrl}/`; + return new URL(encodedObjectKey, baseUrl).toString(); + } + return new URL(`/assets/${fileId}`, directusPublicUrl).toString(); } @@ -137,11 +184,11 @@ async function getImagesFromFolder(folderName: string, fallbackImages: HomepageB endpoint.searchParams.set('sort', '-uploaded_on'); endpoint.searchParams.set('filter[folder][_eq]', folder.id); endpoint.searchParams.set('filter[type][_starts_with]', 'image/'); - endpoint.searchParams.set('fields', 'id,title,description,filename_download,type'); + endpoint.searchParams.set('fields', 'id,title,description,filename_download,filename_disk,type'); const files = await readDirectusEndpoint(endpoint); const images = files.map((file) => ({ - src: resolveDirectusAssetUrl(file.id), + src: resolveDirectusAssetUrl(file), alt: file.description || file.title || file.filename_download || 'Swansea Airport', })); @@ -195,9 +242,9 @@ async function getRecurringEvents(): Promise { const endpoint = new URL('/items/event_dates', directusUrl); endpoint.searchParams.set('limit', '100'); endpoint.searchParams.set('sort', 'date'); - endpoint.searchParams.set( - 'fields', - 'id,date,template.id,template.title,template.slug,template.description,template.image,template.logo,template.booking_url', + endpoint.searchParams.set( + 'fields', + 'id,date,template.id,template.title,template.slug,template.description,template.image.id,template.image.filename_download,template.image.filename_disk,template.logo.id,template.logo.filename_download,template.logo.filename_disk,template.booking_url', ); const eventDates = await readDirectusEndpoint(endpoint); diff --git a/src/lib/fallback-data.ts b/src/lib/fallback-data.ts index aec1a3c..e03a47a 100644 --- a/src/lib/fallback-data.ts +++ b/src/lib/fallback-data.ts @@ -27,8 +27,8 @@ export type EventItem = { end_datetime?: string; location_text?: string; registration_link?: string; - realimage?: string | { id?: string; filename_download?: string; title?: string }; - logo?: string | { id?: string; filename_download?: string; title?: string }; + realimage?: string | { id?: string; filename_download?: string; filename_disk?: string; title?: string }; + logo?: string | { id?: string; filename_download?: string; filename_disk?: string; title?: string }; status?: string; is_featured?: boolean; tags?: string[]; diff --git a/src/pages/events/[slug].astro b/src/pages/events/[slug].astro index db98bf5..4039fe8 100644 --- a/src/pages/events/[slug].astro +++ b/src/pages/events/[slug].astro @@ -1,40 +1,41 @@ --- import BaseLayout from '../../layouts/BaseLayout.astro'; -import { getEvents } from '../../lib/directus'; +import { getEvents, resolveDirectusAssetUrl } from '../../lib/directus'; import { getUpcomingEvents } from '../../lib/events'; import { formatDate, formatDateTime } from '../../lib/format'; import { normalizeSlug } from '../../lib/slug'; type EventItem = Awaited>[number]; -declare const process: { - env: Record; -}; - function resolveEventImageSource(realimage: EventItem['realimage']): string | null { if (!realimage) return null; const candidate = typeof realimage === 'string' ? realimage : realimage.id; if (!candidate) return null; - const configuredDirectusPublicUrl = process.env.DIRECTUS_PUBLIC_URL; - const directusPort = process.env.DIRECTUS_PORT ?? '8066'; - const localFallbackUrl = `${Astro.url.protocol}//${Astro.url.hostname}:${directusPort}`; - const directusBaseUrl = - configuredDirectusPublicUrl && !configuredDirectusPublicUrl.includes('example.com') - ? configuredDirectusPublicUrl - : localFallbackUrl; + if (typeof realimage !== 'string' && realimage.id) { + return resolveDirectusAssetUrl({ + id: realimage.id, + filename_download: realimage.filename_download, + }); + } - if (candidate.startsWith('/')) { - return new URL(candidate, directusBaseUrl).toString(); + if (candidate.startsWith('/assets/')) { + return resolveDirectusAssetUrl(candidate.slice('/assets/'.length).split('/')[0]); } if (candidate.startsWith('http://') || candidate.startsWith('https://')) { const candidateUrl = new URL(candidate); - return candidateUrl.pathname.startsWith('/assets/') ? new URL(candidateUrl.pathname, directusBaseUrl).toString() : candidate; + return candidateUrl.pathname.startsWith('/assets/') + ? resolveDirectusAssetUrl(candidateUrl.pathname.slice('/assets/'.length).split('/')[0]) + : candidate; } - return new URL(`/assets/${candidate}`, directusBaseUrl).toString(); + if (candidate.startsWith('/')) { + return candidate; + } + + return resolveDirectusAssetUrl(candidate); } export async function getStaticPaths() {