diff --git a/helpers/parse.js b/helpers/parse.js index 24487131e2..e5623a0f04 100644 --- a/helpers/parse.js +++ b/helpers/parse.js @@ -124,3 +124,23 @@ export const md = (options = {}) => { } export const renderString = (string) => configuredXss.process(md().render(string)) + +export const escapeXmlAttr = (unsafe) => { + if (!unsafe) { + return + } + return unsafe.replace(/[<>&'"]/g, function (c) { + switch (c) { + case '<': + return '<' + case '>': + return '>' + case '&': + return '&' + case "'": + return ''' + case '"': + return '"' + } + }) +} diff --git a/package.json b/package.json index 8066711b28..f8a39a6b15 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "dependencies": { "@ltd/j-toml": "^1.38.0", "dayjs": "^1.11.7", + "feed": "^4.2.2", "floating-vue": "^2.0.0-beta.20", "highlight.js": "^11.7.0", "js-yaml": "^4.1.0", diff --git a/server/routes/feed/[feed_type]/notifications.js b/server/routes/feed/[feed_type]/notifications.js new file mode 100644 index 0000000000..42909f8531 --- /dev/null +++ b/server/routes/feed/[feed_type]/notifications.js @@ -0,0 +1,77 @@ +import { Feed } from 'feed' +import { renderString } from '~/helpers/parse' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const API_URL = config.apiBaseUrl + const WEBSITE_URL = config.public.siteUrl + const authorization = getHeader(event, 'Authorization') + + if (authorization === undefined) { + setResponseStatus(event, 401) + return 'Please pass a valid authentication token to view your notifications as an RSS feed.' + } + + try { + const userInfo = await $fetch(API_URL + 'user', { + headers: { + Authorization: authorization, + }, + }) + + const userNotifications = await $fetch(API_URL + `user/${userInfo.id}/notifications`, { + headers: { + Authorization: authorization, + }, + }) + + const feed = new Feed({ + title: `Notifications for ${userInfo.username}`, + link: WEBSITE_URL + '/notifications', + generator: 'Modrinth', + id: WEBSITE_URL + '/notifications', + description: `${userInfo.username} has ${userNotifications.length} notification${ + userNotifications.length === 1 ? '' : 's' + }`, + feedLinks: { + json: WEBSITE_URL + '/feed/json/notifications', + atom: WEBSITE_URL + '/feed/atom/notifications', + rss: WEBSITE_URL + '/feed/rss/notifications', + }, + }) + + userNotifications.forEach((notification) => { + feed.addItem({ + title: notification.title, + description: renderString(notification.text), + id: WEBSITE_URL + notification.link, + link: WEBSITE_URL + notification.link, + date: new Date(notification.created), + author: [ + { + name: userInfo.username, + link: WEBSITE_URL + `/user/${userInfo.id}`, + }, + ], + }) + }) + + switch (event.context.params.feed_type.toLowerCase()) { + case 'rss': + setResponseHeader(event, 'Content-Type', 'application/rss+xml') + return feed.rss2() + case 'atom': + setResponseHeader(event, 'Content-Type', 'application/atom+xml') + return feed.atom1() + case 'json': + setResponseHeader(event, 'Content-Type', 'application/feed+json') + return feed.json1() + default: + setResponseStatus(event, 500) + return 'Invalid Feed Type' + } + } catch (e) { + setResponseStatus(event, 401) + return 'There was an error generating the feed.\n\n' + e + } +}) diff --git a/server/routes/feed/[feed_type]/project/[id].js b/server/routes/feed/[feed_type]/project/[id].js new file mode 100644 index 0000000000..0b78c6a81f --- /dev/null +++ b/server/routes/feed/[feed_type]/project/[id].js @@ -0,0 +1,90 @@ +import { Feed } from 'feed' +import { renderString, escapeXmlAttr } from '~/helpers/parse' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + const API_URL = config.apiBaseUrl + const WEBSITE_URL = config.public.siteUrl + + const projectInformation = await $fetch(API_URL + 'project/' + event.context.params.id) + const projectVersions = await $fetch(API_URL + 'project/' + event.context.params.id + '/version') + const projectTeam = await $fetch(API_URL + 'project/' + event.context.params.id + '/members') + + let featuredImage = projectInformation.gallery.filter((image) => image.featured)[0] + + if (featuredImage) { + featuredImage = featuredImage.url + } + + const feed = new Feed({ + title: projectInformation.title, + id: WEBSITE_URL + `/${projectInformation.project_type}/${projectInformation.id}`, + description: + `${projectInformation.title} is a ${projectInformation.project_type} with ${ + projectInformation.downloads + } download${projectInformation.downloads > 1 ? 's' : ''}` + + `${ + projectInformation.followers > 0 + ? 'and ' + projectInformation.followers + ' follower' + projectInformation.followers > 1 + ? 's' + : '' + : '' + } that is available on Modrinth, an open-source platform to host mods, modpacks, shaders, resource packs, plugins and datapacks.`, + feedLinks: { + json: WEBSITE_URL + `/feed/json/project/${projectInformation.id}`, + atom: WEBSITE_URL + `/feed/atom/project/${projectInformation.id}`, + rss: WEBSITE_URL + `/feed/rss/project/${projectInformation.id}`, + }, + generator: 'Modrinth', + link: WEBSITE_URL + `/${projectInformation.project_type}/${projectInformation.id}`, + language: 'en', + updated: new Date(projectInformation.updated), + favicon: projectInformation.icon_url ?? 'https://cdn.modrinth.com/placeholder.png', + image: featuredImage ?? undefined, + }) + + projectVersions.forEach((version) => { + feed.addItem({ + title: `New Version Released: ${version.name}`, + id: + WEBSITE_URL + + `/${projectInformation.project_type}/${projectInformation.id}/version/${version.id}`, + link: + WEBSITE_URL + + `/${projectInformation.project_type}/${projectInformation.id}/version/${version.id}`, + content: escapeXmlAttr( + `This version is for ${version.loaders.join( + ', ' + )} and works on the following Minecraft versions: ${version.game_versions.join(', ')}
` + + // Check for changelog length being greater than 1 to ensure no blank changelog section. + `

Changelog

${renderString( + version.changelog.length > 1 ? version.changelog : 'No changelog was specified.' + )}` + ), + author: [ + ...projectTeam.map((member) => { + return { + name: member.user.username, + link: WEBSITE_URL + `/user/${member.user.id}`, + } + }), + ], + date: new Date(version.date_published), + }) + }) + + switch (event.context.params.feed_type.toLowerCase()) { + case 'rss': + setResponseHeader(event, 'Content-Type', 'application/rss+xml') + return feed.rss2() + case 'atom': + setResponseHeader(event, 'Content-Type', 'application/atom+xml') + return feed.atom1() + case 'json': + setResponseHeader(event, 'Content-Type', 'application/feed+json') + return feed.json1() + default: + setResponseStatus(event, 500) + return 'Invalid Feed Type' + } +})