diff options
| author | Fuwn <[email protected]> | 2026-06-02 12:59:04 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-06-02 12:59:04 +0000 |
| commit | 76d710493e2496490f9e2f9894cf581757f4d92e (patch) | |
| tree | dd7b196588d26dd1134322c526ec43eb9721de61 | |
| parent | feat(security): add AES-GCM feed-token helper (M5) (diff) | |
| download | due.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.svelte | 6 | ||||
| -rw-r--r-- | src/lib/Utility/anilistOauth.ts | 28 | ||||
| -rw-r--r-- | src/routes/feeds/activity-notifications/+server.ts | 64 | ||||
| -rw-r--r-- | src/routes/settings/+page.server.ts | 14 | ||||
| -rw-r--r-- | src/routes/settings/+page.svelte | 2 |
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> |