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.
+ `