From a131f51f66cd0797e8af6c516d05d5e6b70b9a05 Mon Sep 17 00:00:00 2001 From: Alexander Heimbuch Date: Sat, 11 Jan 2025 17:00:48 +0100 Subject: [PATCH] feat(page): use dns entries for custom domains --- apps/page/package.json | 7 +- apps/page/src/env.d.ts | 11 +++ apps/page/src/layouts/Layout.astro | 3 +- apps/page/src/lib/dns-record.ts | 30 ++++++++ apps/page/src/lib/json.ts | 7 ++ apps/page/src/logic/store/selectors.ts | 26 +++++-- .../src/logic/store/stores/router.store.ts | 11 ++- .../src/logic/store/stores/runtime.store.ts | 1 + apps/page/src/middleware/custom-domain.ts | 20 +++++ apps/page/src/middleware/index.ts | 14 ++-- apps/page/src/middleware/request-param.ts | 34 +++++++++ apps/page/src/middleware/router.ts | 24 +++--- apps/page/src/middleware/store.ts | 18 +++-- apps/page/src/pages/episode/[episodeId].astro | 2 + .../page/src/pages/feed/[...feed]/index.astro | 11 +-- apps/page/src/pages/index.astro | 72 ------------------ apps/page/src/pages/proxy.ts | 13 ---- apps/page/src/pages/search.astro | 74 +++++++++++++++++++ pnpm-lock.yaml | 39 ++++++++-- 19 files changed, 286 insertions(+), 131 deletions(-) create mode 100644 apps/page/src/lib/dns-record.ts create mode 100644 apps/page/src/lib/json.ts create mode 100644 apps/page/src/middleware/custom-domain.ts create mode 100644 apps/page/src/middleware/request-param.ts create mode 100644 apps/page/src/pages/episode/[episodeId].astro delete mode 100644 apps/page/src/pages/proxy.ts create mode 100644 apps/page/src/pages/search.astro diff --git a/apps/page/package.json b/apps/page/package.json index 91b6bc9ab..c9f403601 100644 --- a/apps/page/package.json +++ b/apps/page/package.json @@ -40,7 +40,9 @@ "ndarray": "1.0.19", "quantize": "1.0.2", "@heroicons/vue": "2.2.0", - "sanitize-html": "2.14.0" + "sanitize-html": "2.14.0", + "@layered/dns-records": "2.1.0", + "tldts": "6.1.71" }, "devDependencies": { "@types/lodash-es": "4.17.12", @@ -50,6 +52,7 @@ "@types/get-pixels": "3.3.4", "@types/quantize": "1.0.2", "@types/ndarray": "1.0.14", - "@types/sanitize-html": "2.13.0" + "@types/sanitize-html": "2.13.0", + "@cloudflare/workers-types": "4.20250109.0" } } diff --git a/apps/page/src/env.d.ts b/apps/page/src/env.d.ts index acef35f17..caf428228 100644 --- a/apps/page/src/env.d.ts +++ b/apps/page/src/env.d.ts @@ -1,2 +1,13 @@ /// /// + +type KVNamespace = import("@cloudflare/workers-types").KVNamespace; +type ENV = { + CUSTOM_DOMAINS: KVNamespace; +}; + +// use a default runtime configuration (advanced mode). +type Runtime = import("@astrojs/cloudflare").Runtime; +declare namespace App { + interface Locals extends Runtime {} +} diff --git a/apps/page/src/layouts/Layout.astro b/apps/page/src/layouts/Layout.astro index 1e8efd3cc..4fc81ebe9 100644 --- a/apps/page/src/layouts/Layout.astro +++ b/apps/page/src/layouts/Layout.astro @@ -10,7 +10,7 @@ import Colors from '../features/Colors.vue'; import { store } from '../logic'; import { getLanguage } from '../i18n'; -const { title, description, favicon } = Astro.props; +const { title, description, favicon, baseUrl } = Astro.props; const state = store.getState(); const lang = getLanguage(); --- @@ -25,6 +25,7 @@ const lang = getLanguage(); {title} + diff --git a/apps/page/src/lib/dns-record.ts b/apps/page/src/lib/dns-record.ts new file mode 100644 index 000000000..e7b114ea3 --- /dev/null +++ b/apps/page/src/lib/dns-record.ts @@ -0,0 +1,30 @@ +import { getDnsRecords } from '@layered/dns-records'; +import { get, noop } from 'lodash-es'; +import type { APIContext } from 'astro'; +import { getDomain } from 'tldts'; +import { safeParse } from './json'; + +type FeedData = { feed: string | null; primary_color: string | null }; + +const getStore = (context: APIContext): KVNamespace => + get(context, ['locals', 'runtime', 'env', 'CUSTOM_DOMAINS'], { + get: async () => null, + put: noop + } as unknown as KVNamespace); + +export const extractDnsData = async (context: APIContext): Promise => { + const domain = getDomain(context.url.hostname); + const entryName = `lux.${domain}`; + const store = getStore(context); + + let result = await store.get(entryName); + + if (!result) { + const dnsEntry = await getDnsRecords(entryName, 'TXT'); + result = get(dnsEntry, ['data', 0], null); + result && store.put(entryName, result, { expirationTtl: 60 * 60 }); + } + + const fallback = { feed: null, primary_color: null }; + return safeParse(result || JSON.stringify(fallback), fallback); +}; diff --git a/apps/page/src/lib/json.ts b/apps/page/src/lib/json.ts new file mode 100644 index 000000000..516d414e0 --- /dev/null +++ b/apps/page/src/lib/json.ts @@ -0,0 +1,7 @@ +export const safeParse = (input: string, fallback: T) => { + try { + return JSON.parse(input); + } catch (err) { + return fallback; + } +} diff --git a/apps/page/src/logic/store/selectors.ts b/apps/page/src/logic/store/selectors.ts index 356d0b330..1f0b8263d 100644 --- a/apps/page/src/logic/store/selectors.ts +++ b/apps/page/src/logic/store/selectors.ts @@ -66,19 +66,19 @@ const chaptersImage = createSelector(slices.player, player.chaptersImage); // router const base = createSelector(slices.router, router.base); - +const customDomain = createSelector(slices.router, router.customDomain); const translation = (key: string, attr = {}) => ({ key, attr }); export default { initialized: (state: State) => { - return state.theme.initialized + return state.theme.initialized; }, runtime: { initialized: createSelector(slices.runtime, runtime.initialized), locale: createSelector(slices.runtime, runtime.locale), cacheKey, buildDate: createSelector(slices.runtime, runtime.buildDate), - version: createSelector(slices.runtime, runtime.version), + version: createSelector(slices.runtime, runtime.version) }, podcast: { show: createSelector(slices.podcast, podcast.show), @@ -118,7 +118,7 @@ export default { }, theme: { colors: createSelector(slices.theme, theme.colors), - initialized: createSelector(slices.theme, theme.initialized), + initialized: createSelector(slices.theme, theme.initialized) }, show: { poster: showPoster, @@ -238,11 +238,21 @@ export default { router: { base, episodeId: createSelector(slices.router, router.episodeId), - index: createSelector([base, feed], (...args) => args.filter(Boolean).join('/')), + index: createSelector([base, feed, customDomain], (base, feed, customDomain) => { + if (customDomain) { + return '/'; + } + + return [base, feed].filter(Boolean).join('/'); + }), episode: (episodeId: string) => - createSelector([base, feed], (...args) => - [...args, 'episode', episodeId].filter(Boolean).join('/') - ) + createSelector([base, feed, customDomain], (base, feed, customDomain) => { + if (customDomain) { + return ['episode', episodeId].filter(Boolean).join('/'); + } + + return [base, feed, 'episode', episodeId].filter(Boolean).join('/'); + }) }, a11y: { chapterNext: (state: State) => { diff --git a/apps/page/src/logic/store/stores/router.store.ts b/apps/page/src/logic/store/stores/router.store.ts index 8d72b6338..41efed8ac 100644 --- a/apps/page/src/logic/store/stores/router.store.ts +++ b/apps/page/src/logic/store/stores/router.store.ts @@ -1,9 +1,11 @@ import { last } from 'lodash-es'; import { createAction, handleActions, type Action } from 'redux-actions'; +import { actions as runtimeActions } from "./runtime.store"; export interface State { path: string[]; + customDomain: boolean; } export type navigatePayload = string[]; @@ -26,10 +28,14 @@ const updatePath = (state: State, { payload }: Action) => ({ export const reducer = handleActions( { + [runtimeActions.initializeApp.toString()]: (state, action: ReturnType) => ({ + ...state, + customDomain: action.payload.customDomain + }), [actions.navigate.toString()]: updatePath, [actions.setRoute.toString()]: updatePath }, - { path: [] } + { path: [], customDomain: true } ); export const selectors = { @@ -57,5 +63,6 @@ export const selectors = { default: return null; } - } + }, + customDomain: (state: State) => state.customDomain }; diff --git a/apps/page/src/logic/store/stores/runtime.store.ts b/apps/page/src/logic/store/stores/runtime.store.ts index a58fb6c00..e9330ae24 100644 --- a/apps/page/src/logic/store/stores/runtime.store.ts +++ b/apps/page/src/logic/store/stores/runtime.store.ts @@ -14,6 +14,7 @@ export interface initializeAppPayload { feed: string; locale: string; episodeId?: number; + customDomain: boolean; } export type dataFetchedPayload = { diff --git a/apps/page/src/middleware/custom-domain.ts b/apps/page/src/middleware/custom-domain.ts new file mode 100644 index 000000000..dad06ff8e --- /dev/null +++ b/apps/page/src/middleware/custom-domain.ts @@ -0,0 +1,20 @@ +import { defineMiddleware } from "astro:middleware"; +import { getRequestParams } from "./request-param"; + +export const handleCustomDomain = defineMiddleware(async ({ request, rewrite, originPathname }, next) => { + const { feed, episodeId } = getRequestParams(request); + + if (feed && episodeId && originPathname !== `/feed/${feed}/episode/${episodeId}`) { + return rewrite(`/feed/${feed}/episode/${episodeId}`); + } + + if (feed && originPathname !== '/feed') { + return rewrite('/feed'); + } + + if (!feed && originPathname !== '/search') { + return rewrite('/search'); + } + + return next(); +}); diff --git a/apps/page/src/middleware/index.ts b/apps/page/src/middleware/index.ts index b237dc863..0785af32f 100644 --- a/apps/page/src/middleware/index.ts +++ b/apps/page/src/middleware/index.ts @@ -2,9 +2,13 @@ import { sequence } from 'astro:middleware'; import { initializeStore } from './store'; import { setEtag } from './caching'; -import { defineMiddlewareRouter } from './router' +import { defineMiddlewareRouter } from './router'; +import { extractRequestParams } from './request-param'; +import { handleCustomDomain } from './custom-domain'; -export const onRequest = defineMiddlewareRouter({ - '/feed/**': sequence(initializeStore, setEtag), - '/proxy**': sequence() -}) +export const onRequest = defineMiddlewareRouter([ + ['/feed/**', sequence(extractRequestParams, initializeStore, setEtag)], + ['/api/**', sequence()], + ['/search**', sequence()], + ['/**', sequence(extractRequestParams, handleCustomDomain, initializeStore, setEtag)] +]); diff --git a/apps/page/src/middleware/request-param.ts b/apps/page/src/middleware/request-param.ts new file mode 100644 index 000000000..30fb601e5 --- /dev/null +++ b/apps/page/src/middleware/request-param.ts @@ -0,0 +1,34 @@ +import { defineMiddleware } from 'astro:middleware'; +import { get } from 'lodash-es'; +import { extractDnsData } from '../lib/dns-record'; + + +export const getRequestParams = (request: Request): { feed: string | undefined; episodeId: string | undefined; customDomain: boolean; } => { + const { feed, episodeId, customDomain } = get(request, 'data') as unknown as { + feed: string; + episodeId: string; + customDomain: boolean; + }; + + return { + feed, + episodeId, + customDomain + } +}; + +export const extractRequestParams = defineMiddleware(async (context, next) => { + const { request, params } = context; + let { feed, episodeId } = params; + + const dns = await extractDnsData(context); + + if (dns.feed) { + feed = dns.feed; + } + + (request as any).data = { feed, episodeId, customDomain: !!dns.feed }; + + return next(); +}); + diff --git a/apps/page/src/middleware/router.ts b/apps/page/src/middleware/router.ts index 3d4d98ff2..3883a716a 100644 --- a/apps/page/src/middleware/router.ts +++ b/apps/page/src/middleware/router.ts @@ -1,13 +1,17 @@ -import { defineMiddleware, sequence } from 'astro:middleware'; +import type { MiddlewareHandler } from 'astro'; +import { defineMiddleware } from 'astro:middleware'; import multimatch from 'multimatch'; -export function defineMiddlewareRouter(router: Record) { - const entries = Object.entries(router); - return defineMiddleware((context, next) => - sequence( - ...entries - .filter(([path]) => multimatch(context.url.pathname, path).length > 0) - .map(([_, handler]) => handler) - )(context, next) - ); +export function defineMiddlewareRouter(entries: [string, MiddlewareHandler][]): MiddlewareHandler { + return defineMiddleware((context, next) => { + const match = entries.find(([path]) => multimatch(context.url.pathname, path).length > 0); + + if (!match) { + return next(); + } + + const [, routeHandler] = match; + + return routeHandler(context, next); + }); } diff --git a/apps/page/src/middleware/store.ts b/apps/page/src/middleware/store.ts index 84053cb4b..0bdfc4583 100644 --- a/apps/page/src/middleware/store.ts +++ b/apps/page/src/middleware/store.ts @@ -5,20 +5,26 @@ import { getRequestHeader } from '../lib/middleware'; import parseFeed from '../logic/data/feed-parser'; import type { Podcast } from '../types/feed.types'; import { createHash } from '../lib/caching'; +import { getRequestParams } from './request-param'; const version = import.meta.env.VITE_COMMIT_HASH; -console.log({ version }) - -export const initializeStore = defineMiddleware(async ({ request, params }, next) => { +export const initializeStore = defineMiddleware(async ({ request }, next) => { const locale = getRequestHeader(request, 'accept-language', 'en-US'); - const { feed, episodeId } = params; + const { feed, episodeId, customDomain } = getRequestParams(request); if (!feed) { - throw Error('Missing Feed Url'); + throw new Error('Missing Feed'); } - store.dispatch(actions.lifecycle.initializeApp({ feed, locale, episodeId: toInteger(episodeId) })); + store.dispatch( + actions.lifecycle.initializeApp({ + feed, + locale, + episodeId: toInteger(episodeId), + customDomain + }) + ); const data: Podcast = await parseFeed({ feed, episodeId: toInteger(episodeId) }); const cacheKey: string | null = data.etag ? await createHash(`${data.etag}${version}`) : null; diff --git a/apps/page/src/pages/episode/[episodeId].astro b/apps/page/src/pages/episode/[episodeId].astro new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/apps/page/src/pages/episode/[episodeId].astro @@ -0,0 +1,2 @@ +--- +--- diff --git a/apps/page/src/pages/feed/[...feed]/index.astro b/apps/page/src/pages/feed/[...feed]/index.astro index 14484fea3..9c0492f5c 100644 --- a/apps/page/src/pages/feed/[...feed]/index.astro +++ b/apps/page/src/pages/feed/[...feed]/index.astro @@ -5,6 +5,8 @@ import { actions, selectors, store } from '../../../logic'; import HeroIndex from '../../../screens/archive/Hero.vue'; import LoadMore from '../../../screens/archive/LoadMore.vue'; +const baseUrl = Astro.originPathname; + const state = store.getState(); const title = selectors.podcast.title(state); const favicon = selectors.podcast.poster(state); @@ -12,8 +14,9 @@ const description = selectors.podcast.description(state); store.dispatch(actions.router.setRoute(['feed'])); --- - - + + +
@@ -22,6 +25,4 @@ store.dispatch(actions.router.setRoute(['feed'])); - + diff --git a/apps/page/src/pages/index.astro b/apps/page/src/pages/index.astro index b82c548b4..a845151cc 100644 --- a/apps/page/src/pages/index.astro +++ b/apps/page/src/pages/index.astro @@ -1,74 +1,2 @@ --- -import { getLanguage } from '../i18n'; -import FeedSearch from '../features/feed-search/FeedSearch.vue'; -const version = import.meta.env.VITE_COMMIT_HASH; -const lang = getLanguage(); --- - - - - - - - - - - Podlove Lux - - - -
-
-

- -
- Podlove - Illuminate your Feed -
Lux -

-
-
-
-
- -
-
-
-
- Version: {version} -
-
- - diff --git a/apps/page/src/pages/proxy.ts b/apps/page/src/pages/proxy.ts deleted file mode 100644 index 2a15bd2a7..000000000 --- a/apps/page/src/pages/proxy.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { APIRoute } from 'astro'; - -export const GET: APIRoute = async ({ url }) => { - const requestUrl = url.searchParams.get('url'); - - if (!requestUrl) { - throw new Error('Missing url'); - } - - const response = await fetch(requestUrl).then(res => res.body); - - return new Response(response); -}; diff --git a/apps/page/src/pages/search.astro b/apps/page/src/pages/search.astro new file mode 100644 index 000000000..b82c548b4 --- /dev/null +++ b/apps/page/src/pages/search.astro @@ -0,0 +1,74 @@ +--- +import { getLanguage } from '../i18n'; +import FeedSearch from '../features/feed-search/FeedSearch.vue'; +const version = import.meta.env.VITE_COMMIT_HASH; +const lang = getLanguage(); +--- + + + + + + + + + + Podlove Lux + + + +
+
+

+ +
+ Podlove + Illuminate your Feed +
Lux +

+
+
+
+
+ +
+
+ + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b336b034..5cc40bd27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@heroicons/vue': specifier: 2.2.0 version: 2.2.0(vue@3.5.12) + '@layered/dns-records': + specifier: 2.1.0 + version: 2.1.0 '@m31coding/fuzzy-search': specifier: 1.0.1 version: 1.0.1 @@ -171,6 +174,9 @@ importers: scroll-into-view-if-needed: specifier: 3.1.0 version: 3.1.0 + tldts: + specifier: 6.1.71 + version: 6.1.71 vue: specifier: 3.5.12 version: 3.5.12(typescript@5.3.2) @@ -178,6 +184,9 @@ importers: specifier: 9.13.1 version: 9.13.1(vue@3.5.12) devDependencies: + '@cloudflare/workers-types': + specifier: 4.20250109.0 + version: 4.20250109.0 '@types/get-pixels': specifier: 3.3.4 version: 3.3.4 @@ -1002,7 +1011,7 @@ packages: dependencies: '@astrojs/internal-helpers': 0.4.1 '@astrojs/underscore-redirects': 0.4.0 - '@cloudflare/workers-types': 4.20241205.0 + '@cloudflare/workers-types': 4.20250109.0 '@inox-tools/astro-when': 1.0.1(astro@5.1.1) astro: 5.1.1(@types/node@18.18.14)(typescript@5.3.2) esbuild: 0.24.0 @@ -1011,7 +1020,7 @@ packages: miniflare: 3.20241205.0 tiny-glob: 0.2.9 vite: 6.0.3(@types/node@18.18.14) - wrangler: 3.93.0(@cloudflare/workers-types@4.20241205.0) + wrangler: 3.93.0(@cloudflare/workers-types@4.20250109.0) transitivePeerDependencies: - '@types/node' - bufferutil @@ -2022,9 +2031,8 @@ packages: zod: 3.23.8 dev: false - /@cloudflare/workers-types@4.20241205.0: - resolution: {integrity: sha512-pj1VKRHT/ScQbHOIMFODZaNAlJHQHdBSZXNIdr9ebJzwBff9Qz8VdqhbhggV7f+aUEh8WSbrsPIo4a+WtgjUvw==} - dev: false + /@cloudflare/workers-types@4.20250109.0: + resolution: {integrity: sha512-Y1zgSaEOOevl9ORpzgMcm4j535p3nK2lrblHHvYM2yxR50SBKGh+wvkRFAIxWRfjUGZEU+Fp6923EGioDBbobA==} /@codemirror/commands@6.3.2: resolution: {integrity: sha512-tjoi4MCWDNxgIpoLZ7+tezdS9OEB6pkiDKhfKx9ReJ/XBcs2G2RXIu+/FxXBlWsPTsz6C9q/r4gjzrsxpcnqCQ==} @@ -3380,6 +3388,12 @@ packages: '@jridgewell/sourcemap-codec': 1.5.0 dev: false + /@layered/dns-records@2.1.0: + resolution: {integrity: sha512-7JjDasWK6xAltjfIIg4KmiHNW7Tzreq/8IXUvskZhYu9MMQiy/3HABRdzFa/E6V6rvaSl4Asne8LkeL7DONRHQ==} + dependencies: + punycode: 2.3.1 + dev: false + /@lerna/create@8.0.0(typescript@5.0.4): resolution: {integrity: sha512-mCeEhjFDRwPY7J4uxCjqdzPwPFBUGlkdlQjBidaX5XaoQcxR2hAAvgHZKfVGkUUEZKfyPcWwKzen4KydNB2G7A==} engines: {node: '>=18.0.0'} @@ -16127,6 +16141,17 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tldts-core@6.1.71: + resolution: {integrity: sha512-LRbChn2YRpic1KxY+ldL1pGXN/oVvKfCVufwfVzEQdFYNo39uF7AJa/WXdo+gYO7PTvdfkCPCed6Hkvz/kR7jg==} + dev: false + + /tldts@6.1.71: + resolution: {integrity: sha512-LQIHmHnuzfZgZWAf2HzL83TIIrD8NhhI0DVxqo9/FdOd4ilec+NTNZOlDZf7EwrTNoutccbsHjvWHYXLAtvxjw==} + hasBin: true + dependencies: + tldts-core: 6.1.71 + dev: false + /tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -18363,7 +18388,7 @@ packages: '@cloudflare/workerd-windows-64': 1.20241205.0 dev: false - /wrangler@3.93.0(@cloudflare/workers-types@4.20241205.0): + /wrangler@3.93.0(@cloudflare/workers-types@4.20250109.0): resolution: {integrity: sha512-+wfxjOrtm6YgDS+NdJkB6aiBIS3ED97mNRQmfrEShRJW4pVo4sWY6oQ1FsGT+j4tGHplrTbWCE6U5yTgjNW/lw==} engines: {node: '>=16.17.0'} hasBin: true @@ -18375,7 +18400,7 @@ packages: dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@cloudflare/workers-shared': 0.10.0 - '@cloudflare/workers-types': 4.20241205.0 + '@cloudflare/workers-types': 4.20250109.0 '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) blake3-wasm: 2.1.5