Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(page): use dns entries for custom domains #1177

Merged
merged 2 commits into from
Jan 12, 2025
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: 4 additions & 2 deletions apps/page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"ndarray": "1.0.19",
"quantize": "1.0.2",
"@heroicons/vue": "2.2.0",
"sanitize-html": "2.14.0"
"sanitize-html": "2.14.0",
"tldts": "6.1.71"
},
"devDependencies": {
"@types/lodash-es": "4.17.12",
Expand All @@ -50,6 +51,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"
}
}
11 changes: 11 additions & 0 deletions apps/page/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

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<ENV>;
declare namespace App {
interface Locals extends Runtime {}
}
3 changes: 2 additions & 1 deletion apps/page/src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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();
---
Expand All @@ -25,6 +25,7 @@ const lang = getLanguage();
<meta name="generator" content="Podlove Lux" />
<title>{title}</title>
<meta name="description" content={description} />
<base href={baseUrl} />
<script is:inline define:vars={{ state }}>
window.REDUX_STATE = state;
</script>
Expand Down
63 changes: 63 additions & 0 deletions apps/page/src/lib/dns-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 };
type DnsAnswer = {
name: string;
type: number;
TTL: number;
data: string;
};

const getDnsRecords = async (hostname: string): Promise<DnsAnswer[]> =>
fetch(`https://cloudflare-dns.com/dns-query?name=${hostname}&type=TXT`, {
headers: {
Accept: 'application/dns-json'
}
})
.then((res) => res.json())
.then((result) => get(result, ['Answer'], []));

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<FeedData> => {
const domain = getDomain(context.url.hostname);
const entryName = `lux.${domain}`;
const store = getStore(context);
const fallback = { feed: null, primary_color: null };

let result = await store.get(entryName);

if (!result) {
result = await getDnsRecords(entryName)
.then(
([result]) => get(result, 'data', null),
() => null
)
.then((result) => {
if (!result) {
return null;
}

try {
return atob(result.replace(/['"]+/g, ''));
} catch (err) {
return null;
}
});
}

if (!result) {
result = JSON.stringify(fallback);
}

store.put(entryName, result, { expirationTtl: 60 * 60 });

return safeParse<FeedData>(result, fallback);
};
26 changes: 11 additions & 15 deletions apps/page/src/lib/get-image-color.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import quantize from 'quantize';
import { isDark } from 'farbraum';
import { type rgbColor } from '../types/color.types';
import ndarray from 'ndarray';

const fetchImage = async (
imageUrl: string
): Promise<{ data: ArrayBuffer; dimensions: { width: number; height: number } }> =>
): Promise<{
data: Uint8ClampedArray<ArrayBufferLike>;
dimensions: { width: number; height: number };
}> =>
new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
Expand All @@ -30,7 +32,7 @@ const parseImage = ({
data,
dimensions
}: {
data: ArrayBuffer;
data: Uint8ClampedArray<ArrayBufferLike>;
dimensions: { width: number; height: number };
}): ndarray.NdArray =>
ndarray(
Expand All @@ -55,29 +57,23 @@ const convertToPixels = (pixels: ndarray.NdArray): quantize.RgbPixel[] => {
return result;
};

const extractColors = (
pixels: quantize.RgbPixel[]
): { primaryColor: rgbColor | null; complementaryColor: rgbColor | null } => {
const extractColors = (pixels: quantize.RgbPixel[]): rgbColor | null => {
const colorPalette = quantize(pixels, 5);

if (!colorPalette) {
return { primaryColor: null, complementaryColor: null };
return null;
}

const [primaryColor] = colorPalette.palette();
const complementaryColor: rgbColor = isDark(primaryColor) ? [240, 240, 240] : [1, 1, 1];

return { primaryColor, complementaryColor };
return primaryColor;
};

const getImageColors = async (
src: string
): Promise<{ primaryColor: rgbColor | null; complementaryColor: rgbColor | null }> =>
const getImagePrimaryColor = async (src: string): Promise<rgbColor | null> =>
fetchImage(src)
.then(parseImage)
.then(convertToPixels)
.then(extractColors)
.catch(() => null);

.catch(() => ({ primaryColor: null, complementaryColor: null }));

export default getImageColors;
export default getImagePrimaryColor;
7 changes: 7 additions & 0 deletions apps/page/src/lib/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const safeParse = <T>(input: string, fallback: T) => {
try {
return JSON.parse(input);
} catch (err) {
return fallback;
}
}
3 changes: 2 additions & 1 deletion apps/page/src/logic/sagas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export async function createSideEffects() {
selectSearchOverlayVisible: selectors.search.visible,
selectSubscribeOverlayVisible: selectors.subscribeButton.visible,
selectShowPoster: selectors.podcast.poster,
selectFeed: selectors.podcast.feed
selectFeed: selectors.podcast.feed,
selectThemeInitialized: selectors.theme.initialized
})
] as any[];

Expand Down
39 changes: 30 additions & 9 deletions apps/page/src/logic/sagas/layout.sagas.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
import type { EventChannel } from 'redux-saga';
import { call, fork, put, select, takeEvery } from 'redux-saga/effects';
import { channel } from '@podlove/player-sagas/helper';
import { lighten } from 'farbraum';
import { isDark, lighten } from 'farbraum';
import type { Action } from 'redux-actions';

import actions from '../store/actions';
import { isClient } from '../../lib/runtime';
import type { ColorTokens, rgbColor } from '../../types/color.types';
import getImageColors from '../../lib/get-image-color';
import type { initializeThemePayload } from '../store/stores/theme.store';
import { isArray } from 'lodash-es';

export default function ({
selectSubscribeOverlayVisible,
selectSearchOverlayVisible,
selectShowPoster
selectShowPoster,
selectThemeInitialized
}: {
selectSubscribeOverlayVisible: (input: any) => boolean;
selectSearchOverlayVisible: (input: any) => boolean;
selectShowPoster: (input: any) => string | null;
selectFeed: (input: any) => string | null;
selectThemeInitialized: (input: any) => boolean;
}) {
function* disableOverflow() {
document.body.classList.add('overflow-hidden');
Expand All @@ -34,7 +39,7 @@ export default function ({
yield put(actions.view.stopLoading());
}

function* initializeTheme() {
function* initializeTheme({ payload }: Action<initializeThemePayload>) {
const poster: string | null = yield select(selectShowPoster);

const tailwindColorTokens = (color: rgbColor | null): ColorTokens | null => {
Expand All @@ -43,6 +48,7 @@ export default function ({
if (!color) {
return null;
}

return tokens.reduce(
(result, token) => ({
...result,
Expand All @@ -52,13 +58,22 @@ export default function ({
) as ColorTokens;
};

if (!poster) {
let primaryColor: rgbColor | null = null;

if (isArray(payload.primaryColor)) {
primaryColor = payload.primaryColor;
}

if (!primaryColor && poster) {
primaryColor = yield getImageColors(`/api/proxy?url=${poster}`);
}

if (!primaryColor) {
return;
}

const { primaryColor, complementaryColor } = yield getImageColors(`/api/proxy?url=${poster}`);
const primary = tailwindColorTokens(primaryColor);
const complementary = tailwindColorTokens(complementaryColor);
const complementary = tailwindColorTokens(isDark(primaryColor) ? [240, 240, 240] : [1, 1, 1]);

yield put(
actions.theme.setTheme({
Expand All @@ -71,18 +86,24 @@ export default function ({
}

return function* () {
if (isClient()) {
yield fork(initializeTheme);
yield takeEvery(actions.theme.initializeTheme.toString(), initializeTheme);

if (isClient()) {
const pageLoadStart: EventChannel<KeyboardEvent> = yield call(channel, (cb: EventListener) =>
document.addEventListener('astro:before-preparation', cb)
);
const pageLoadEnd: EventChannel<KeyboardEvent> = yield call(channel, (cb: EventListener) =>
document.addEventListener('astro:after-preparation', cb)
);

yield takeEvery(pageLoadStart, startLoading);
yield takeEvery(pageLoadEnd, stopLoading);

const initialized: boolean = yield select(selectThemeInitialized);

if (!initialized) {
yield put(actions.theme.initializeTheme({ primaryColor: null }));
}

}

while (true) {
Expand Down
26 changes: 18 additions & 8 deletions apps/page/src/logic/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
11 changes: 9 additions & 2 deletions apps/page/src/logic/store/stores/router.store.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -26,10 +28,14 @@ const updatePath = (state: State, { payload }: Action<string[]>) => ({

export const reducer = handleActions<State, any>(
{
[runtimeActions.initializeApp.toString()]: (state, action: ReturnType<typeof runtimeActions.initializeApp>) => ({
...state,
customDomain: action.payload.customDomain
}),
[actions.navigate.toString()]: updatePath,
[actions.setRoute.toString()]: updatePath
},
{ path: [] }
{ path: [], customDomain: true }
);

export const selectors = {
Expand Down Expand Up @@ -57,5 +63,6 @@ export const selectors = {
default:
return null;
}
}
},
customDomain: (state: State) => state.customDomain
};
1 change: 1 addition & 0 deletions apps/page/src/logic/store/stores/runtime.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface initializeAppPayload {
feed: string;
locale: string;
episodeId?: number;
customDomain: boolean;
}

export type dataFetchedPayload = {
Expand Down
Loading
Loading