diff --git a/api/api/v1/accounts/[id]/feed.atom.ts b/api/api/v1/accounts/[id]/feed.atom.ts new file mode 100644 index 00000000..55eb3868 --- /dev/null +++ b/api/api/v1/accounts/[id]/feed.atom.ts @@ -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); + }, + ), +); diff --git a/api/api/v1/accounts/[id]/feed.rss.ts b/api/api/v1/accounts/[id]/feed.rss.ts new file mode 100644 index 00000000..5e9bc314 --- /dev/null +++ b/api/api/v1/accounts/[id]/feed.rss.ts @@ -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); + }, + ), +); diff --git a/bun.lock b/bun.lock index 2394b177..064ac277 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/package.nix b/nix/package.nix index 91ee6cd0..d05b20ec 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -21,7 +21,7 @@ in pnpmDeps = pnpm.fetchDeps { inherit (finalAttrs) pname version src pnpmInstallFlags; - hash = "sha256-fY6Rx4wSI5e5d6ACj9Jh08lpWI38qeiOZQyY7+eI/c4="; + hash = "sha256-VPFYBNYbdwa1a3RVv4aieXMyzIGt3PGrRzQbih3WM8Y="; }; nativeBuildInputs = [ diff --git a/package.json b/package.json index 1531be59..6089e98e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75fa0281..75edab85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/utils/rss.ts b/utils/rss.ts new file mode 100644 index 00000000..f9d61725 --- /dev/null +++ b/utils/rss.ts @@ -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 => { + 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; +};