feat(api): Add RSS and Atom feed functionality

This commit is contained in:
Jesse Wierzbinski 2025-05-01 22:35:32 +02:00
parent 70aff2df68
commit 3832328aaf
No known key found for this signature in database
7 changed files with 222 additions and 1 deletions

View file

@ -0,0 +1,61 @@
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { getFeed } from "@/rss";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/feed.atom",
describeRoute({
summary: "Get account's Atom feed",
description:
"Statuses posted to the given account, in Atom 1.0 format.",
tags: ["Accounts"],
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/atom+xml": {
schema: resolver(z.any()),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
validator(
"query",
z.object({
page: z.coerce.number().default(0).openapi({
description: "Page number to fetch. Defaults to 0.",
example: 2,
}),
}),
handleZodError,
),
async (context) => {
const otherUser = context.get("user");
const { page } = context.req.valid("query");
const feed = await getFeed(otherUser, page);
context.header("Content-Type", "application/atom+xml");
return context.body(feed.atom1(), 200);
},
),
);

View file

@ -0,0 +1,60 @@
import { RolePermission } from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, handleZodError, withUserParam } from "@/api";
import { getFeed } from "@/rss";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/accounts/:id/feed.rss",
describeRoute({
summary: "Get account's RSS feed",
description: "Statuses posted to the given account, in RSS format.",
tags: ["Accounts"],
responses: {
200: {
description: "Statuses posted to the given account.",
content: {
"application/rss+xml": {
schema: resolver(z.any()),
},
},
},
404: ApiError.accountNotFound().schema,
422: ApiError.validationFailed().schema,
},
}),
withUserParam,
auth({
auth: false,
permissions: [
RolePermission.ViewNotes,
RolePermission.ViewAccounts,
],
scopes: ["read:statuses"],
}),
validator(
"query",
z.object({
page: z.coerce.number().default(0).openapi({
description: "Page number to fetch. Defaults to 0.",
example: 2,
}),
}),
handleZodError,
),
async (context) => {
const otherUser = context.get("user");
const { page } = context.req.valid("query");
const feed = await getFeed(otherUser, page);
context.header("Content-Type", "application/rss+xml");
return context.body(feed.rss2(), 200);
},
),
);

View file

@ -28,6 +28,7 @@
"clerc": "^0.44.0",
"confbox": "^0.2.2",
"drizzle-orm": "^0.43.1",
"feed": "^4.2.2",
"hono": "^4.7.8",
"hono-openapi": "^0.4.8",
"hono-rate-limiter": "^0.4.2",
@ -804,6 +805,8 @@
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
"feed": ["feed@4.2.2", "", { "dependencies": { "xml-js": "^1.6.11" } }, "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ=="],
"figures": ["figures@5.0.0", "", { "dependencies": { "escape-string-regexp": "^5.0.0", "is-unicode-supported": "^1.2.0" } }, "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg=="],
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
@ -1158,6 +1161,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="],
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
@ -1312,6 +1317,8 @@
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
"xss": ["xss@1.0.15", "", { "dependencies": { "commander": "^2.20.3", "cssfilter": "0.0.10" }, "bin": { "xss": "bin/xss" } }, "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],

View file

@ -21,7 +21,7 @@ in
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src pnpmInstallFlags;
hash = "sha256-fY6Rx4wSI5e5d6ACj9Jh08lpWI38qeiOZQyY7+eI/c4=";
hash = "sha256-VPFYBNYbdwa1a3RVv4aieXMyzIGt3PGrRzQbih3WM8Y=";
};
nativeBuildInputs = [

View file

@ -106,6 +106,7 @@
"clerc": "^0.44.0",
"confbox": "^0.2.2",
"drizzle-orm": "^0.43.1",
"feed": "^4.2.2",
"hono": "^4.7.8",
"hono-openapi": "^0.4.8",
"hono-rate-limiter": "^0.4.2",

View file

@ -80,6 +80,9 @@ importers:
drizzle-orm:
specifier: ^0.43.1
version: 0.43.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(bun-types@1.2.11)
feed:
specifier: ^4.2.2
version: 4.2.2
hono:
specifier: ^4.7.8
version: 4.7.8
@ -2231,6 +2234,10 @@ packages:
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
feed@4.2.2:
resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==}
engines: {node: '>=0.4.0'}
figures@5.0.0:
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
engines: {node: '>=14'}
@ -2946,6 +2953,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
search-insights@2.17.3:
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
@ -3285,6 +3295,10 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
xml-js@1.6.11:
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
hasBin: true
xss@1.0.15:
resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==}
engines: {node: '>= 0.10.0'}
@ -5138,6 +5152,10 @@ snapshots:
dependencies:
reusify: 1.1.0
feed@4.2.2:
dependencies:
xml-js: 1.6.11
figures@5.0.0:
dependencies:
escape-string-regexp: 5.0.0
@ -5853,6 +5871,8 @@ snapshots:
safer-buffer@2.1.2: {}
sax@1.4.1: {}
search-insights@2.17.3: {}
section-matter@1.0.0:
@ -6253,6 +6273,10 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.0
xml-js@1.6.11:
dependencies:
sax: 1.4.1
xss@1.0.15:
dependencies:
commander: 2.20.3

68
utils/rss.ts Normal file
View file

@ -0,0 +1,68 @@
import { and, eq, inArray } from "drizzle-orm";
import { Feed } from "feed";
import { Note } from "~/classes/database/note";
import type { User } from "~/classes/database/user";
import { config } from "~/config";
import { Notes } from "~/drizzle/schema";
export const getFeed = async (user: User, page = 0): Promise<Feed> => {
const notes = await Note.manyFromSql(
and(
eq(Notes.authorId, user.id),
// Visibility check
inArray(Notes.visibility, ["public", "unlisted"]),
),
undefined,
20,
page * 20,
);
const feed = new Feed({
link: new URL(
`/api/v1/accounts/${user.id}/feed.rss`,
config.http.base_url,
).href,
id: new URL(
`/api/v1/accounts/${user.id}/feed.rss`,
config.http.base_url,
).href,
language: user.data.source?.language || undefined,
image: user.getAvatarUrl().href,
copyright: `All rights reserved ${new Date().getFullYear()} @${user.data.username}`,
feedLinks: {
atom: new URL(
`/api/v1/accounts/${user.id}/feed.atom`,
config.http.base_url,
).href,
rss: new URL(
`/api/v1/accounts/${user.id}/feed.rss`,
config.http.base_url,
).href,
},
author: {
name: user.data.displayName || user.data.username,
link: new URL(`/@${user.data.username}`, config.http.base_url).href,
},
description: `Public statuses posted by @${user.data.username}`,
title: user.data.displayName || user.data.username,
});
for (const note of notes) {
feed.addItem({
link: new URL(
`/@${user.data.username}/${note.id}`,
config.http.base_url,
).href,
content: note.data.content,
date: new Date(note.data.createdAt),
id: new URL(
`/@${user.data.username}/${note.id}`,
config.http.base_url,
).href,
published: new Date(note.data.createdAt),
title: "",
});
}
return feed;
};