aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/Error/rateLimit.ts64
-rw-r--r--src/lib/Settings/Categories/RSSFeeds.svelte6
-rw-r--r--src/lib/User/BadgeWall/Badges.svelte28
-rw-r--r--src/lib/User/BadgeWall/badges.css9
-rw-r--r--src/lib/Utility/anilistOauth.ts27
-rw-r--r--src/lib/Utility/feedToken.test.ts44
-rw-r--r--src/lib/Utility/feedToken.ts96
-rw-r--r--src/routes/api/badges/+server.ts9
-rw-r--r--src/routes/api/oauth/refresh/+server.ts29
-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
12 files changed, 328 insertions, 64 deletions
diff --git a/src/lib/Error/rateLimit.ts b/src/lib/Error/rateLimit.ts
index 636bdd5d..d3508aba 100644
--- a/src/lib/Error/rateLimit.ts
+++ b/src/lib/Error/rateLimit.ts
@@ -1,12 +1,62 @@
import type { RequestEvent } from "@sveltejs/kit";
-import { RateLimiter } from "sveltekit-rate-limiter/server";
+import {
+ RateLimiter,
+ type RateLimiterStore,
+} from "sveltekit-rate-limiter/server";
-export const checkRateLimit = async (event: RequestEvent) => {
- const limiter = new RateLimiter({ rates: { IP: [5, "s"] } });
+// Storage. The library's default store is in-memory and PER INSTANCE. On
+// Vercel's serverless runtime, counters do not aggregate across lambda
+// instances or regions, so these limiters are best-effort backstops there.
+// Provision a shared store (Upstash Redis / Vercel KV) implementing
+// RateLimiterStore and return it here for durable limits. Coarse per-IP abuse
+// protection also runs at the edge via the Vercel WAF (see
+// docs/vercel-firewall.md); these app-level limiters add what the WAF cannot
+// express — per-user windows and the per-(IP, badge) click counter.
+const createStore = (): RateLimiterStore | undefined => {
+ // e.g. `if (env.UPSTASH_REDIS_REST_URL) return new UpstashStore(...)`
+ return undefined;
+};
- await limiter.cookieLimiter?.preflight(event);
+const store = createStore();
- if (await limiter.isLimited(event)) return new Response("rate-limited");
+// Module-level singletons: the counters must persist across requests. (The
+// previous implementation built a new RateLimiter on every call, so its store
+// was always empty and it never actually limited anything.)
+const limiters = {
+ auth: new RateLimiter({ IP: [10, "m"], store }),
+ mutation: new RateLimiter({ IP: [30, "m"], store }),
+ read: new RateLimiter({ IP: [60, "m"], store }),
+} satisfies Record<string, RateLimiter>;
- return null;
-};
+export type RateLimitClass = keyof typeof limiters;
+
+const tooManyRequests = () =>
+ new Response("Too Many Requests", { status: 429 });
+
+export const checkRateLimit = async (
+ event: RequestEvent,
+ limit: RateLimitClass = "mutation",
+): Promise<Response | null> =>
+ (await limiters[limit].isLimited(event)) ? tooManyRequests() : null;
+
+// Click counter: bound inflation of a single badge without throttling a viewer
+// browsing many DIFFERENT badges. Keyed on (IP, badge) — which the edge WAF
+// can't express, since it keys on IP alone.
+const clickCounterLimiter = new RateLimiter({
+ store,
+ plugins: [
+ {
+ rate: [2, "m"],
+ hash: async (event) => {
+ const badgeId = event.url.searchParams.get("incrementClickCount");
+
+ return badgeId ? `click:${event.getClientAddress()}:${badgeId}` : true;
+ },
+ },
+ ],
+});
+
+export const checkClickCounterLimit = async (
+ event: RequestEvent,
+): Promise<Response | null> =>
+ (await clickCounterLimiter.isLimited(event)) ? tooManyRequests() : null;
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/User/BadgeWall/Badges.svelte b/src/lib/User/BadgeWall/Badges.svelte
index d2c56392..a77aebd6 100644
--- a/src/lib/User/BadgeWall/Badges.svelte
+++ b/src/lib/User/BadgeWall/Badges.svelte
@@ -1,4 +1,6 @@
<script lang="ts">
+import { onDestroy } from "svelte";
+import { browser } from "$app/environment";
import FallbackImage from "$lib/Image/FallbackImage.svelte";
import Spacer from "$lib/Layout/Spacer.svelte";
import LinkedTooltip from "$lib/Tooltip/LinkedTooltip.svelte";
@@ -16,6 +18,30 @@ export let categoryFilter: string | null;
export let editMode: boolean;
export let preferences: Preferences | undefined;
export let selectedBadge: IndexedBadge | undefined = undefined;
+
+const skipObserver =
+ browser && typeof IntersectionObserver !== "undefined"
+ ? new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries)
+ entry.target.classList.toggle(
+ "is-offscreen",
+ !entry.isIntersecting,
+ );
+ },
+ { rootMargin: "600px" },
+ )
+ : null;
+
+const skipWhenOffscreen = (node: HTMLElement) => {
+ skipObserver?.observe(node);
+
+ return {
+ destroy: () => skipObserver?.unobserve(node),
+ };
+};
+
+onDestroy(() => skipObserver?.disconnect());
</script>
{#if ungroupedBadges.length === 0}
@@ -42,7 +68,7 @@ export let selectedBadge: IndexedBadge | undefined = undefined;
<div class="badges">
{#each badges as badge}
- <div id={`badge-${badge.id}`}>
+ <div id={`badge-${badge.id}`} class="is-offscreen" use:skipWhenOffscreen>
{#if editMode}
<LinkedTooltip
content={`${
diff --git a/src/lib/User/BadgeWall/badges.css b/src/lib/User/BadgeWall/badges.css
index 19f8996f..f2e03988 100644
--- a/src/lib/User/BadgeWall/badges.css
+++ b/src/lib/User/BadgeWall/badges.css
@@ -11,6 +11,15 @@
gap: 0.25rem;
}
+/* Off-screen cells skip layout + paint. skipWhenOffscreen toggles this class
+ via IntersectionObserver so the containment — and the clipping it does to
+ the hover tilt, scale, and tooltip — is never active on an on-screen cell,
+ and the auto<->visible flip (which flashes) only ever happens off-screen. */
+.badges > .is-offscreen {
+ content-visibility: auto;
+ contain-intrinsic-size: auto 8rem;
+}
+
.edit-row-2 {
margin-top: -1.25rem;
}
diff --git a/src/lib/Utility/anilistOauth.ts b/src/lib/Utility/anilistOauth.ts
new file mode 100644
index 00000000..9bb570fb
--- /dev/null
+++ b/src/lib/Utility/anilistOauth.ts
@@ -0,0 +1,27 @@
+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, so there is no cookie to re-set.
+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/lib/Utility/feedToken.test.ts b/src/lib/Utility/feedToken.test.ts
new file mode 100644
index 00000000..ffcb5fd7
--- /dev/null
+++ b/src/lib/Utility/feedToken.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { decryptFeedToken, encryptFeedToken } from "./feedToken";
+
+// Fixed 32-byte keys so the cipher is exercised without the FEED_TOKEN_KEY env
+// var (production omits the key argument and reads it from env).
+const key = new Uint8Array(32).fill(7);
+const otherKey = new Uint8Array(32).fill(9);
+
+describe("feed token", () => {
+ // Behaviour gate: the feed must still resolve to the same refresh token it
+ // was minted from, and the cleartext must never appear in the URL blob.
+ it("round-trips the refresh token without leaking it", async () => {
+ const refreshToken = "anilist-refresh-token-abc123";
+ const sealed = await encryptFeedToken(refreshToken, key);
+
+ expect(sealed).not.toContain(refreshToken);
+ expect(await decryptFeedToken(sealed, key)).toBe(refreshToken);
+ });
+
+ it("produces a fresh ciphertext each time (random IV)", async () => {
+ expect(await encryptFeedToken("same-token", key)).not.toBe(
+ await encryptFeedToken("same-token", key),
+ );
+ });
+
+ it("rejects a token sealed with a different key", async () => {
+ const sealed = await encryptFeedToken("secret", otherKey);
+
+ expect(await decryptFeedToken(sealed, key)).toBeNull();
+ });
+
+ it("rejects a tampered ciphertext", async () => {
+ const sealed = await encryptFeedToken("secret", key);
+ const tampered = `${sealed[0] === "A" ? "B" : "A"}${sealed.slice(1)}`;
+
+ expect(await decryptFeedToken(tampered, key)).toBeNull();
+ });
+
+ it("rejects malformed input", async () => {
+ expect(await decryptFeedToken("", key)).toBeNull();
+ expect(await decryptFeedToken("short", key)).toBeNull();
+ expect(await decryptFeedToken("not valid base64url!!", key)).toBeNull();
+ });
+});
diff --git a/src/lib/Utility/feedToken.ts b/src/lib/Utility/feedToken.ts
new file mode 100644
index 00000000..1d26a443
--- /dev/null
+++ b/src/lib/Utility/feedToken.ts
@@ -0,0 +1,96 @@
+import { env } from "$env/dynamic/private";
+
+// Authenticated encryption (AES-256-GCM) for the RSS feed token. The plaintext
+// is the user's long-lived AniList refresh token; the ciphertext is what travels
+// in the public feed URL (?feed=). Without FEED_TOKEN_KEY (server-only) the blob
+// is opaque and tamper-evident, so a leaked feed URL exposes no usable
+// credential — unlike the old ?token=&refresh= form that carried them in clear.
+//
+// WebCrypto (not node:crypto) so the byte types stay Uint8Array — the repo's
+// bun-types make node's Buffer structurally incompatible with crypto params.
+
+const IV_BYTES = 12;
+const TAG_BYTES = 16;
+const KEY_BYTES = 32;
+
+// WebCrypto's BufferSource requires the non-shared ArrayBuffer variant; the bare
+// Uint8Array generic defaults to ArrayBufferLike, which the crypto types reject.
+type KeyBytes = Uint8Array<ArrayBuffer>;
+
+const bytesFromBase64 = (text: string) =>
+ Uint8Array.from(atob(text), (character) => character.charCodeAt(0));
+
+const base64UrlFromBytes = (bytes: Uint8Array): string =>
+ btoa(String.fromCharCode(...bytes))
+ .replaceAll("+", "-")
+ .replaceAll("/", "_")
+ .replaceAll("=", "");
+
+const bytesFromBase64Url = (text: string) =>
+ bytesFromBase64(text.replaceAll("-", "+").replaceAll("_", "/"));
+
+const requireKeyBytes = (): KeyBytes => {
+ const encoded = env.FEED_TOKEN_KEY;
+
+ if (!encoded) throw new Error("FEED_TOKEN_KEY is not set");
+
+ const bytes = bytesFromBase64(encoded);
+
+ if (bytes.length !== KEY_BYTES)
+ throw new Error(`FEED_TOKEN_KEY must decode to ${KEY_BYTES} bytes`);
+
+ return bytes;
+};
+
+const importKey = (keyBytes: KeyBytes): Promise<CryptoKey> =>
+ crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, [
+ "encrypt",
+ "decrypt",
+ ]);
+
+// The optional keyBytes parameter lets the unit test exercise the cipher with a
+// fixed key; production callers omit it and the env key is loaded.
+export const encryptFeedToken = async (
+ refreshToken: string,
+ keyBytes: KeyBytes = requireKeyBytes(),
+): Promise<string> => {
+ const key = await importKey(keyBytes);
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
+ const ciphertext = new Uint8Array(
+ await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv },
+ key,
+ new TextEncoder().encode(refreshToken),
+ ),
+ );
+ const framed = new Uint8Array(iv.length + ciphertext.length);
+
+ framed.set(iv, 0);
+ framed.set(ciphertext, iv.length);
+
+ return base64UrlFromBytes(framed);
+};
+
+export const decryptFeedToken = async (
+ blob: string,
+ keyBytes: KeyBytes = requireKeyBytes(),
+): Promise<string | null> => {
+ try {
+ const framed = bytesFromBase64Url(blob);
+
+ if (framed.length < IV_BYTES + TAG_BYTES) return null;
+
+ const key = await importKey(keyBytes);
+ const iv = framed.subarray(0, IV_BYTES);
+ const ciphertext = framed.subarray(IV_BYTES);
+ const plaintext = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv },
+ key,
+ ciphertext,
+ );
+
+ return new TextDecoder().decode(plaintext);
+ } catch {
+ return null;
+ }
+};
diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts
index 10b63125..a4212f40 100644
--- a/src/routes/api/badges/+server.ts
+++ b/src/routes/api/badges/+server.ts
@@ -15,6 +15,7 @@ import {
} from "$lib/Database/SB/User/badges";
import { decodeAuthCookieOrNull } from "$lib/Effect/authCookie";
import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody";
+import { checkClickCounterLimit } from "$lib/Error/rateLimit";
import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin";
import { isOwnerOrPrivileged } from "$lib/Utility/authorisation";
import privilegedUser from "$lib/Utility/privilegedUser";
@@ -53,8 +54,14 @@ export const DELETE = async ({ url, cookies }) => {
return await badges(identity.id);
};
-export const PUT = async ({ cookies, url, request }) => {
+export const PUT = async (event) => {
+ const { cookies, url, request } = event;
+
if (url.searchParams.get("incrementClickCount") || undefined) {
+ const limited = await checkClickCounterLimit(event);
+
+ if (limited) return limited;
+
if (request.headers.get("origin") !== appOrigin()) return unauthorised();
await incrementClickCount(
diff --git a/src/routes/api/oauth/refresh/+server.ts b/src/routes/api/oauth/refresh/+server.ts
deleted file mode 100644
index 49306076..00000000
--- a/src/routes/api/oauth/refresh/+server.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { redirect } from "@sveltejs/kit";
-import { env } from "$env/dynamic/private";
-import { env as env2 } from "$env/dynamic/public";
-
-export const GET = async ({ url, cookies }) => {
- const formData = new FormData();
-
- formData.append("grant_type", "refresh_token");
- formData.append("client_id", env2.PUBLIC_ANILIST_CLIENT_ID as string);
- formData.append("client_secret", env.ANILIST_CLIENT_SECRET as string);
- formData.append("refresh_token", url.searchParams.get("token") || "");
-
- const newUser = await (
- await fetch("https://anilist.co/api/v2/oauth/token", {
- method: "POST",
- body: formData,
- })
- ).json();
-
- cookies.set("user", JSON.stringify(newUser), {
- path: "/",
- maxAge: 60 * 60 * 24 * 7,
- httpOnly: false,
- sameSite: "lax",
- });
-
- if (url.searchParams.get("redirect")) redirect(303, "/");
- else return Response.json(newUser);
-};
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>