aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-06-02 12:59:04 +0000
committerFuwn <[email protected]>2026-06-02 12:59:04 +0000
commit76d710493e2496490f9e2f9894cf581757f4d92e (patch)
treedd7b196588d26dd1134322c526ec43eb9721de61
parentfeat(security): add AES-GCM feed-token helper (M5) (diff)
downloaddue.moe-76d710493e2496490f9e2f9894cf581757f4d92e.tar.xz
due.moe-76d710493e2496490f9e2f9894cf581757f4d92e.zip
fix(security): replace RSS feed URL tokens with encrypted token (M5)
-rw-r--r--src/lib/Settings/Categories/RSSFeeds.svelte6
-rw-r--r--src/lib/Utility/anilistOauth.ts28
-rw-r--r--src/routes/feeds/activity-notifications/+server.ts64
-rw-r--r--src/routes/settings/+page.server.ts14
-rw-r--r--src/routes/settings/+page.svelte2
5 files changed, 88 insertions, 26 deletions
diff --git a/src/lib/Settings/Categories/RSSFeeds.svelte b/src/lib/Settings/Categories/RSSFeeds.svelte
index 49a6eb5a..303db699 100644
--- a/src/lib/Settings/Categories/RSSFeeds.svelte
+++ b/src/lib/Settings/Categories/RSSFeeds.svelte
@@ -7,19 +7,21 @@ import { appOrigin } from "$lib/Utility/appOrigin";
import locale from "$stores/locale";
import SettingHint from "../SettingHint.svelte";
-export let user: { accessToken: string; refreshToken: string };
+export let feedToken: string | undefined;
</script>
<button
data-umami-event="Copy RSS Feed URL"
onclick={() => {
+ if (!feedToken) return;
+
addNotification(
options({
heading: get(locale)().notifications?.rssCopied ?? 'RSS feed URL copied to clipboard'
})
);
navigator.clipboard.writeText(
- `${appOrigin()}/feeds/activity-notifications?token=${user.accessToken}&refresh=${user.refreshToken}`
+ `${appOrigin()}/feeds/activity-notifications?feed=${feedToken}`
);
}}
>
diff --git a/src/lib/Utility/anilistOauth.ts b/src/lib/Utility/anilistOauth.ts
new file mode 100644
index 00000000..26654ec9
--- /dev/null
+++ b/src/lib/Utility/anilistOauth.ts
@@ -0,0 +1,28 @@
+import { env } from "$env/dynamic/private";
+import { env as publicEnv } from "$env/dynamic/public";
+
+// Exchange a refresh token for a fresh access token WITHOUT touching the auth
+// cookie. Used by the RSS feed, which is polled by an unattended reader that has
+// no session; the interactive /api/oauth/refresh endpoint additionally re-sets
+// the cookie, which this deliberately does not.
+export const refreshAniListToken = async (
+ refreshToken: string,
+): Promise<string | null> => {
+ const formData = new FormData();
+
+ formData.append("grant_type", "refresh_token");
+ formData.append("client_id", publicEnv.PUBLIC_ANILIST_CLIENT_ID as string);
+ formData.append("client_secret", env.ANILIST_CLIENT_SECRET as string);
+ formData.append("refresh_token", refreshToken);
+
+ const response = await fetch("https://anilist.co/api/v2/oauth/token", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) return null;
+
+ const payload = (await response.json()) as { access_token?: string };
+
+ return payload.access_token ?? null;
+};
diff --git a/src/routes/feeds/activity-notifications/+server.ts b/src/routes/feeds/activity-notifications/+server.ts
index 64ba3fdc..145e236e 100644
--- a/src/routes/feeds/activity-notifications/+server.ts
+++ b/src/routes/feeds/activity-notifications/+server.ts
@@ -2,16 +2,15 @@ import {
type Notification,
notifications,
} from "$lib/Data/AniList/notifications";
+import { refreshAniListToken } from "$lib/Utility/anilistOauth";
import { siteUrl } from "$lib/Utility/appOrigin";
-import root from "$lib/Utility/root";
+import { decryptFeedToken } from "$lib/Utility/feedToken";
const htmlEncode = (input: string) => {
return input.replace(/[\u00A0-\u9999<>&]/g, (i) => `&#${i.charCodeAt(0)};`);
};
-const render = (
- posts: Notification[] = [],
-) => `<?xml version="1.0" encoding="UTF-8" ?>
+const channel = (items: string) => `<?xml version="1.0" encoding="UTF-8" ?>
<rss
version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
@@ -27,7 +26,14 @@ xmlns:media="http://search.yahoo.com/mrss/">
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<language>en-US</language>
<snf:logo><url>${siteUrl("/favicon-196x196.png")}</url></snf:logo>
- ${posts
+ ${items}
+ </channel>
+</rss>
+`;
+
+const render = (posts: Notification[] = []) =>
+ channel(
+ posts
.filter((notification: Notification) => notification.type !== undefined)
.map((notification: Notification) => {
let title = `${notification.user.name}${notification.context}`;
@@ -66,28 +72,40 @@ xmlns:media="http://search.yahoo.com/mrss/">
<pubDate>${new Date(notification.createdAt * 1000).toUTCString()}</pubDate>
</item>`;
})
- .join("")}
- </channel>
-</rss>
-`;
-
-export const GET = async ({ url }) => {
- let token = url.searchParams.get("token");
- const refresh = url.searchParams.get("refresh");
- let notification = await notifications(token || "");
+ .join(""),
+ );
- if (notification === null) {
- token = (
- await (await fetch(root(`/api/oauth/refresh?token=${refresh}`))).json()
- ).access_token;
+// Old feed URLs carried the tokens in clear (?token=&refresh=); they no longer
+// resolve, so degrade to a single item nudging the user to re-copy the link
+// instead of silently going empty.
+const staleNotice = () =>
+ channel(`<item>
+<guid isPermaLink="false">due-moe-feed-url-outdated</guid>
+<title>${htmlEncode("Your due.moe RSS feed URL is outdated — re-copy it from Settings")}</title>
+<link>${siteUrl("/settings#feeds")}</link>
+<description>${htmlEncode("This feed link no longer works. Open due.moe → Settings → RSS Feeds and copy the new URL into your reader.")}</description>
+<pubDate>${new Date().toUTCString()}</pubDate>
+</item>`);
- notification = await notifications(token as string);
- }
-
- return new Response(token ? render(notification || []) : render(), {
+const feed = (body: string) =>
+ new Response(body, {
headers: {
- "Cache-Control": `max-age=0`,
+ "Cache-Control": "max-age=0",
"Content-Type": "application/xml",
},
});
+
+export const GET = async ({ url }) => {
+ const sealed = url.searchParams.get("feed");
+ const refreshToken = sealed ? await decryptFeedToken(sealed) : null;
+
+ if (!refreshToken) return feed(staleNotice());
+
+ const accessToken = await refreshAniListToken(refreshToken);
+
+ if (!accessToken) return feed(render());
+
+ const notification = await notifications(accessToken);
+
+ return feed(render(notification || []));
};
diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts
new file mode 100644
index 00000000..321e5cfd
--- /dev/null
+++ b/src/routes/settings/+page.server.ts
@@ -0,0 +1,14 @@
+import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
+import { encryptFeedToken } from "$lib/Utility/feedToken";
+
+// Mint the RSS feed token server-side: the encryption key never reaches the
+// client, so the URL is built here from the refresh token already in the cookie
+// rather than from tokens handed to the browser.
+export const load = async ({ cookies }) => {
+ const cookie = cookies.get("user");
+ const user = cookie ? decodeAuthCookieOrNull(cookie) : null;
+
+ return {
+ feedToken: user ? await encryptFeedToken(user.refreshToken) : undefined,
+ };
+};
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte
index 79642944..9a3bf990 100644
--- a/src/routes/settings/+page.svelte
+++ b/src/routes/settings/+page.svelte
@@ -55,7 +55,7 @@ export let data: PageData;
<SettingSync />
</Category>
<Category title={$locale().settings.rssFeeds.title} id="feeds" newLine={false}>
- <RssFeeds user={data.user} />
+ <RssFeeds feedToken={data.feedToken} />
</Category>
</div>