mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 07:25:48 +01:00
🏗️ Migrate rss to tRPC
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from '~/server/api/trpc';
|
||||
import { appRouter } from './routers/app';
|
||||
import { rssRouter } from './routers/rss';
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@@ -8,6 +9,7 @@ import { appRouter } from './routers/app';
|
||||
*/
|
||||
export const rootRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
rss: rssRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
185
src/server/api/routers/rss.ts
Normal file
185
src/server/api/routers/rss.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user