🏗️ Migrate rss to tRPC

This commit is contained in:
Meier Lukas
2023-06-10 11:07:49 +02:00
parent d89e9fb36d
commit dc5bcbe9b2
3 changed files with 236 additions and 34 deletions

View File

@@ -1,5 +1,6 @@
import { createTRPCRouter } from '~/server/api/trpc'; import { createTRPCRouter } from '~/server/api/trpc';
import { appRouter } from './routers/app'; import { appRouter } from './routers/app';
import { rssRouter } from './routers/rss';
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -8,6 +9,7 @@ import { appRouter } from './routers/app';
*/ */
export const rootRouter = createTRPCRouter({ export const rootRouter = createTRPCRouter({
app: appRouter, app: appRouter,
rss: rssRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -0,0 +1,185 @@
import { z } from 'zod';
import RssParser from 'rss-parser';
import Consola from 'consola';
import { decode, encode } from 'html-entities';
import xss from 'xss';
import { TRPCError } from '@trpc/server';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { Stopwatch } from '~/tools/shared/time/stopwatch.tool';
import { getConfig } from '~/tools/config/getConfig';
import { IRssWidget } from '~/widgets/rss/RssWidgetTile';
type CustomItem = {
'media:content': string;
enclosure: {
url: string;
};
};
const rssFeedResultObjectSchema = z
.object({
success: z.literal(false),
feed: z.undefined(),
})
.or(
z.object({
success: z.literal(true),
feed: z.object({
title: z.string().or(z.undefined()),
items: z.array(
z.object({
link: z.string(),
enclosure: z
.object({
url: z.string(),
})
.or(z.undefined()),
categories: z.array(z.string()).or(z.undefined()),
title: z.string(),
content: z.string(),
pubDate: z.string(),
})
),
}),
})
);
export const rssRouter = createTRPCRouter({
all: publicProcedure
.input(
z.object({
widgetId: z.string().uuid(),
feedUrls: z.array(z.string()),
configName: z.string(),
})
)
.output(z.array(rssFeedResultObjectSchema))
.query(async ({ input }) => {
const config = getConfig(input.configName);
const rssWidget = config.widgets.find((x) => x.type === 'rss' && x.id === input.widgetId) as
| IRssWidget
| undefined;
if (!rssWidget || input.feedUrls.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'required widget does not exist',
});
}
const result = await Promise.all(
input.feedUrls.map(async (feedUrl) =>
getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent)
)
);
return result;
}),
});
const getFeedUrl = async (feedUrl: string, dangerousAllowSanitizedItemContent: boolean) => {
Consola.info(`Requesting RSS feed at url ${feedUrl}`);
const stopWatch = new Stopwatch();
const feed = await parser.parseURL(feedUrl);
Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`);
const orderedFeed = {
...feed,
items: feed.items
.map(
(item: {
title: string;
content: string;
'content:encoded': string;
categories: string[] | { _: string }[];
}) => ({
...item,
categories: item.categories
?.map((category) => (typeof category === 'string' ? category : category._))
.filter((category: unknown): category is string => typeof category === 'string'),
title: item.title ? decode(item.title) : undefined,
content: processItemContent(
item['content:encoded'] ?? item.content,
dangerousAllowSanitizedItemContent
),
enclosure: createEnclosure(item),
link: createLink(item),
})
)
.sort((a: { pubDate: number }, b: { pubDate: number }) => {
if (!a.pubDate || !b.pubDate) {
return 0;
}
return a.pubDate - b.pubDate;
})
.slice(0, 20),
};
return {
feed: orderedFeed,
success: orderedFeed?.items !== undefined,
};
};
const processItemContent = (content: string, dangerousAllowSanitizedItemContent: boolean) => {
if (dangerousAllowSanitizedItemContent) {
return xss(content, {
allowList: {
p: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
a: ['href'],
b: [],
strong: [],
i: [],
em: [],
img: ['src', 'width', 'height'],
br: [],
small: [],
ul: [],
li: [],
ol: [],
figure: [],
svg: [],
code: [],
mark: [],
blockquote: [],
},
});
}
return encode(content);
};
const createLink = (item: any) => {
if (item.link) {
return item.link;
}
return item.guid;
};
const createEnclosure = (item: any) => {
if (item.enclosure) {
return item.enclosure;
}
if (item['media:content']) {
return {
url: item['media:content'].$.url,
};
}
return undefined;
};
const parser: RssParser<any, CustomItem> = new RssParser({
customFields: {
item: ['media:content', 'enclosure'],
},
});

View File

@@ -15,11 +15,12 @@ import {
createStyles, createStyles,
} from '@mantine/core'; } from '@mantine/core';
import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react'; import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Link from 'next/link'; import Link from 'next/link';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
@@ -65,27 +66,11 @@ interface RssTileProps {
widget: IRssWidget; widget: IRssWidget;
} }
export const useGetRssFeeds = (feedUrls: string[], refreshInterval: number, widgetId: string) =>
useQuery({
queryKey: ['rss-feeds', feedUrls],
// Cache the results for 24 hours
cacheTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * refreshInterval,
queryFn: async () => {
const responses = await Promise.all(
feedUrls.map((feedUrl) =>
fetch(
`/api/modules/rss?widgetId=${widgetId}&feedUrl=${encodeURIComponent(feedUrl)}`
).then((response) => response.json())
)
);
return responses;
},
});
function RssTile({ widget }: RssTileProps) { function RssTile({ widget }: RssTileProps) {
const { t } = useTranslation('modules/rss'); const { t } = useTranslation('modules/rss');
const { name: configName } = useConfigContext();
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds( const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds(
configName,
widget.properties.rssFeedUrl, widget.properties.rssFeedUrl,
widget.properties.refreshInterval, widget.properties.refreshInterval,
widget.id widget.id
@@ -122,6 +107,7 @@ function RssTile({ widget }: RssTileProps) {
<Title order={6}>{t('descriptor.card.errors.general.title')}</Title> <Title order={6}>{t('descriptor.card.errors.general.title')}</Title>
<Text align="center">{t('descriptor.card.errors.general.text')}</Text> <Text align="center">{t('descriptor.card.errors.general.text')}</Text>
</Stack> </Stack>
<RefetchButton refetch={refetch} isFetching={isFetching} />
</Center> </Center>
); );
} }
@@ -192,6 +178,37 @@ function RssTile({ widget }: RssTileProps) {
))} ))}
</ScrollArea> </ScrollArea>
<RefetchButton refetch={refetch} isFetching={isFetching} />
</Stack>
);
}
export const useGetRssFeeds = (
configName: string | undefined,
feedUrls: string[],
refreshInterval: number,
widgetId: string
) =>
api.rss.all.useQuery(
{
configName: configName ?? '',
feedUrls,
widgetId,
},
{
// Cache the results for 24 hours
cacheTime: 1000 * 60 * 60 * 24,
staleTime: 1000 * 60 * refreshInterval,
enabled: !!configName,
}
);
interface RefetchButtonProps {
refetch: () => void;
isFetching: boolean;
}
const RefetchButton = ({ isFetching, refetch }: RefetchButtonProps) => (
<ActionIcon <ActionIcon
size="sm" size="sm"
radius="xl" radius="xl"
@@ -207,9 +224,7 @@ function RssTile({ widget }: RssTileProps) {
> >
{isFetching ? <Loader /> : <IconRefresh />} {isFetching ? <Loader /> : <IconRefresh />}
</ActionIcon> </ActionIcon>
</Stack> );
);
}
const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => ( const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => (
<Group mt="auto" spacing="xs"> <Group mt="auto" spacing="xs">