aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-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
7 files changed, 264 insertions, 10 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;
+ }
+};