diff --git a/src/components/resources/ResourcesList.tsx b/src/components/resources/ResourcesList.tsx index 8d724938..af30fb37 100644 --- a/src/components/resources/ResourcesList.tsx +++ b/src/components/resources/ResourcesList.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Resource } from '@/types/resources'; import ResourceCard from './ResourceCard'; -import { IconFolderX, IconX } from '@tabler/icons-react'; +import { IconAlertTriangle, IconFolderX, IconRefresh, IconX } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; @@ -28,6 +28,8 @@ interface ResourcesListProps { filteredResources: Resource[]; onMusicLink?: (resource: Resource) => void; onAnimate?: (resource: Resource) => void; + loadError?: string | null; + onRetry?: () => void; } const ResourcesList = ({ @@ -42,6 +44,8 @@ const ResourcesList = ({ filteredResources, onMusicLink, onAnimate, + loadError, + onRetry, }: ResourcesListProps) => { const [currentPage, setCurrentPage] = React.useState(1); const itemsPerPage = 12; @@ -70,6 +74,64 @@ const ResourcesList = ({ ); } + // A failed catalog fetch must never be presented as "no resources": that reads + // as an empty library when in fact the request (usually the asset session) failed. + if (loadError && resources.length === 0) { + return ( + + + + + + Couldn't load resources + + + {loadError}. This is usually a blocked or interrupted connection to our asset + service. Check your connection, VPN or ad-blocker, then try again. + + + + + + + ); + } + const shouldShowNoResourcesMessage = (!isLoading && filteredResources.length === 0) || (!isLoading && @@ -169,6 +231,19 @@ const ResourcesList = ({ animate={{ opacity: 1 }} transition={{ duration: 0.5 }} > + {loadError && resources.length > 0 && ( +
+

+ Some resources could not be refreshed: {loadError} +

+ {onRetry && ( + + )} +
+ )} { const [loadedFonts, setLoadedFonts] = useState([]); const [fontPreviewText, setFontPreviewText] = useState("Aa Bb Cc"); const [availableCategories, setAvailableCategories] = useState([]); + const [loadError, setLoadError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const fetchIdRef = useRef(0); @@ -44,6 +45,7 @@ export const useResources = () => { const loadResources = async () => { setIsLoading(true); setResources([]); + setLoadError(null); try { const categories = getAvailableCategories(); @@ -66,6 +68,9 @@ export const useResources = () => { console.error("Error fetching resources:", error); if (currentFetchId === fetchIdRef.current) { setResources([]); + setLoadError( + error instanceof Error && error.message ? error.message : "Unable to load resources" + ); } } finally { if (currentFetchId === fetchIdRef.current) { @@ -267,6 +272,11 @@ export const useResources = () => { return cacheImage(url); }, []); + const retryResources = useCallback(() => { + fetchIdRef.current++; + setRefreshKey(prev => prev + 1); + }, []); + return { resources, selectedResource, @@ -286,6 +296,7 @@ export const useResources = () => { availableSubcategories, availableCategories, hasCategoryResources, + loadError, handleSearchSubmit, handleClearSearch, handleCategoryChange, @@ -302,5 +313,6 @@ export const useResources = () => { setRefreshKey(prev => prev + 1); setSelectedCategory(null); }, + retryResources, }; }; diff --git a/src/lib/api.ts b/src/lib/api.ts index 9fb5f4f3..d4ffd01a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import { readCache, writeCache, clearCache } from "@/lib/cache"; +import { readCache, readStaleCache, writeCache, clearCache } from "@/lib/cache"; import { fetchFromAssetsApi } from "@/lib/assetsApi"; import { ensureAssetsVersionSynced } from "@/lib/assetsVersion"; import { Resource } from "@/types/resources"; @@ -9,6 +9,18 @@ const CATEGORY_CACHE_PREFIX = "api:category:"; export const MCICONS_BLACKLISTED_SUBCATEGORIES = ['backgrounds']; +/** + * Thrown when the catalog could not be loaded at all (session/network failure and + * no usable cache). Callers must surface this instead of rendering an empty list, + * which is indistinguishable from "there are no resources". + */ +export class ResourceApiError extends Error { + constructor(message = "Unable to load resources") { + super(message); + this.name = "ResourceApiError"; + } +} + export interface ApiResource { id: number; title: string; @@ -92,6 +104,58 @@ const normalizeApiResource = (item: ApiResource, category: string): Resource | n }; }; +const flattenCategories = (categories: Record): Resource[] => + Object.entries(categories).flatMap(([category, items]) => + items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) + ); + +const readCachedCatalog = (): ApiAllResources | null => { + const fresh = readCache(ALL_RESOURCES_CACHE_KEY); + if (fresh?.categories && Object.keys(fresh.categories).length > 0) return fresh; + const stale = readStaleCache(ALL_RESOURCES_CACHE_KEY); + if (stale?.categories && Object.keys(stale.categories).length > 0) return stale; + return null; +}; + +const resourceFromCatalog = (catalog: ApiAllResources, apiCategory: string): Resource[] => { + const items = catalog.categories?.[apiCategory]; + if (!items || items.length === 0) return []; + return items + .map(item => normalizeApiResource(item, apiCategory)) + .filter((r): r is Resource => r !== null); +}; + +const CATEGORY_NAMES = [ + "music", + "sfx", + "images", + "animations", + "fonts", + "presets", + "mcsounds", + "minecraft-icons", +]; + +/** + * Rebuilds the catalog from the per-category caches. Last resort when `/all` is + * unreachable but individual categories were cached earlier. + */ +const readResourcesFromCategoryCaches = (): Resource[] => { + const collected: Resource[] = []; + + for (const category of CATEGORY_NAMES) { + const cacheKey = `${CATEGORY_CACHE_PREFIX}${category}`; + const cached = readCache(cacheKey) ?? readStaleCache(cacheKey); + if (!cached || cached.length === 0) continue; + const apiCategory = toApiCategory(category); + collected.push( + ...cached.map(item => normalizeApiResource(item, apiCategory)).filter((r): r is Resource => r !== null) + ); + } + + return collected; +}; + export const fetchCategories = async (): Promise => { await ensureAssetsVersionSynced(); const cached = readCache(CATEGORIES_CACHE_KEY); @@ -102,7 +166,7 @@ export const fetchCategories = async (): Promise => { writeCache(CATEGORIES_CACHE_KEY, data); return data; } - return null; + return readStaleCache(CATEGORIES_CACHE_KEY); }; export const fetchCategory = async (category: string): Promise => { @@ -120,16 +184,32 @@ export const fetchCategory = async (category: string): Promise => { const data = await fetchJson<{ category: string; files: ApiResource[] }>( `/category/${apiCategory}` ); - if (data?.files && data.files.length > 0) { - try { - writeCache(cacheKey, data.files); - } catch (e) { - console.warn(`Failed to cache category ${normalized}:`, e); + if (data && Array.isArray(data.files)) { + // A successful response is authoritative, even when it is empty. + if (data.files.length > 0) { + try { + writeCache(cacheKey, data.files); + } catch (e) { + console.warn(`Failed to cache category ${normalized}:`, e); + } } return data.files.map(item => normalizeApiResource(item, normalized)).filter((r): r is Resource => r !== null); } - console.error(`Failed to fetch category ${normalized} from API`); - return []; + + // Unreachable upstream: serve what we already have rather than nothing. + const stale = readStaleCache(cacheKey); + if (stale && stale.length > 0) { + console.warn(`Using stale cache for category ${normalized}`); + return stale.map(item => normalizeApiResource(item, normalized)).filter((r): r is Resource => r !== null); + } + + const catalog = readCachedCatalog(); + if (catalog) { + const fromCatalog = resourceFromCatalog(catalog, apiCategory); + if (fromCatalog.length > 0) return fromCatalog; + } + + throw new ResourceApiError(`Unable to load the "${normalized}" category`); }; export const fetchAllResources = async (): Promise => { @@ -146,41 +226,54 @@ export const fetchAllResources = async (): Promise => { total: categoryNames.length }); } - return Object.entries(cached.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(cached.categories); } const data = await fetchJson("/all"); - if (data?.categories && Object.keys(data.categories).length > 0) { - try { - writeCache(ALL_RESOURCES_CACHE_KEY, data); - const categoryNames = Object.keys(data.categories) - .map(normalizeCategory) - .filter((c): c is Resource["category"] => c !== null); - writeCache(CATEGORIES_CACHE_KEY, { - categories: categoryNames, - total: categoryNames.length - }); - Object.entries(data.categories).forEach(([category, items]) => { - const normalized = normalizeCategory(category); - if (normalized && items && items.length > 0) { - try { - writeCache(`${CATEGORY_CACHE_PREFIX}${normalized}`, items); - } catch { - // Ignore quota errors for individual category caches + if (data?.categories && typeof data.categories === "object") { + // A successful response is authoritative, even when the catalog is empty. + if (Object.keys(data.categories).length > 0) { + try { + writeCache(ALL_RESOURCES_CACHE_KEY, data); + const categoryNames = Object.keys(data.categories) + .map(normalizeCategory) + .filter((c): c is Resource["category"] => c !== null); + writeCache(CATEGORIES_CACHE_KEY, { + categories: categoryNames, + total: categoryNames.length + }); + Object.entries(data.categories).forEach(([category, items]) => { + const normalized = normalizeCategory(category); + if (normalized && items && items.length > 0) { + try { + writeCache(`${CATEGORY_CACHE_PREFIX}${normalized}`, items); + } catch { + // Ignore quota errors for individual category caches + } } - } - }); - } catch (e) { - console.warn("Failed to cache all resources (likely quota exceeded):", e); + }); + } catch (e) { + console.warn("Failed to cache all resources (likely quota exceeded):", e); + } } - return Object.entries(data.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(data.categories); + } + + // The catalog could not be fetched (expired/blocked session, outage, offline): + // serve the cached copy and only report an error when there is nothing to show. + const stale = readStaleCache(ALL_RESOURCES_CACHE_KEY); + if (stale?.categories && Object.keys(stale.categories).length > 0) { + console.warn("Using stale all-resources cache after a failed catalog fetch"); + return flattenCategories(stale.categories); } - console.error("Failed to fetch all resources from API"); - return []; + + const fromCategoryCaches = readResourcesFromCategoryCaches(); + if (fromCategoryCaches.length > 0) { + console.warn("Rebuilt the catalog from category caches after a failed catalog fetch"); + return fromCategoryCaches; + } + + throw new ResourceApiError("Unable to load resources from the assets API"); }; export const refreshAllResources = async (): Promise => { @@ -188,21 +281,18 @@ export const refreshAllResources = async (): Promise => { const data = await fetchJson("/all"); if (data?.categories) { writeCache(ALL_RESOURCES_CACHE_KEY, data); - return Object.entries(data.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(data.categories); } - return []; + throw new ResourceApiError("Unable to refresh resources from the assets API"); }; export const getResourceByCategory = async (category: string): Promise => { await ensureAssetsVersionSynced(); const apiCategory = toApiCategory(category); - const allCached = readCache(ALL_RESOURCES_CACHE_KEY); - if (allCached?.categories?.[apiCategory]) { - return allCached.categories[apiCategory].map(item => - normalizeApiResource(item, category) - ).filter((r): r is Resource => r !== null); + const catalog = readCachedCatalog(); + if (catalog) { + const fromCatalog = resourceFromCatalog(catalog, apiCategory); + if (fromCatalog.length > 0) return fromCatalog; } return fetchCategory(category); diff --git a/src/lib/assetsApi.ts b/src/lib/assetsApi.ts index 25e71dda..707e5aef 100644 --- a/src/lib/assetsApi.ts +++ b/src/lib/assetsApi.ts @@ -33,9 +33,61 @@ export async function fetchAssetsVersion(): Promise { let sessionPromise: Promise | null = null; +// The gateway hands the session back as an HttpOnly cookie, but that cookie is +// third-party (renderdragon.org -> gateway.codersoft.xyz) and browsers that block +// third-party cookies (Safari/ITP, Brave, Chrome incognito, strict ETP) drop it, +// which used to break every catalog request for those visitors. The same signed +// token is also returned in the session response body and replayed here as a +// bearer token, so the session works with or without cookies. +const SESSION_STORAGE_KEY = "rd-assets-gateway-session"; + +let sessionToken: string | null = null; + +const readStoredToken = (): string | null => { + if (sessionToken) return sessionToken; + try { + const raw = localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { base?: string; token?: string }; + if (parsed?.base !== ASSETS_API_BASE_URL || typeof parsed.token !== "string" || !parsed.token) { + return null; + } + sessionToken = parsed.token; + return sessionToken; + } catch { + return null; + } +}; + +const storeToken = (token: string | null): void => { + sessionToken = token; + try { + if (token) { + localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ base: ASSETS_API_BASE_URL, token }) + ); + } else { + localStorage.removeItem(SESSION_STORAGE_KEY); + } + } catch { + // Private mode / quota: the in-memory token still covers this page load. + } +}; + +const withTokenHeader = (headers: HeadersInit | undefined, token: string | null): Headers => { + const merged = new Headers(headers); + if (token) merged.set("Authorization", `Bearer ${token}`); + return merged; +}; + async function hasSession(): Promise { + const token = readStoredToken(); try { - const response = await fetch(`${ASSETS_API_BASE_URL}/__session`, { credentials: "include" }); + const response = await fetch(`${ASSETS_API_BASE_URL}/__session`, { + credentials: "include", + headers: withTokenHeader(undefined, token), + }); return response.ok; } catch { return false; @@ -60,6 +112,13 @@ async function acquireSession(): Promise { if (!response.ok) { throw new Error(`Unable to establish an assets session (${response.status})`); } + + try { + const body = (await response.json()) as { token?: unknown }; + storeToken(typeof body?.token === "string" && body.token ? body.token : null); + } catch { + storeToken(null); + } } export async function ensureAssetsSession(): Promise { @@ -86,11 +145,18 @@ export async function fetchFromAssetsApi(path: string, init?: RequestInit): Prom await ensureAssetsSession(); - const send = () => fetch(assetsApiUrl(path), { ...init, credentials: "include" }); + const send = () => + fetch(assetsApiUrl(path), { + ...init, + headers: withTokenHeader(init?.headers, readStoredToken()), + credentials: "include", + }); + let response = await send(); if (response.status === 401) { // Session expired or was rejected; re-establish once and retry. + storeToken(null); sessionPromise = null; await ensureAssetsSession(); response = await send(); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 31618760..ebdcc25c 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -7,33 +7,64 @@ type CachedPayload = { data: T; }; -export const readCache = (key: string): T | null => { +const readRawCache = (key: string): CachedPayload | null => { try { const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`); if (!raw) return null; const parsed = JSON.parse(raw) as CachedPayload; if (!parsed?.savedAt || parsed?.data === undefined) return null; - if (Date.now() - parsed.savedAt > CACHE_TTL_MS) { - localStorage.removeItem(`${CACHE_PREFIX}${key}`); - return null; - } - return parsed.data; + return parsed; } catch { return null; } }; +export const readCache = (key: string): T | null => { + const parsed = readRawCache(key); + if (!parsed) return null; + if (Date.now() - parsed.savedAt > CACHE_TTL_MS) return null; + return parsed.data; +}; + +// Reads an entry even when its TTL has passed. Used as a last-resort fallback so a +// visitor still sees the catalog when the asset API (or their session) is unavailable. +export const readStaleCache = (key: string): T | null => + readRawCache(key)?.data ?? null; + +const pruneExpiredCaches = (): void => { + try { + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(CACHE_PREFIX)) continue; + try { + const parsed = JSON.parse(localStorage.getItem(key) || "") as CachedPayload; + if (!parsed?.savedAt || Date.now() - parsed.savedAt > CACHE_TTL_MS) { + localStorage.removeItem(key); + } + } catch { + localStorage.removeItem(key); + } + } + } catch { + // ignore + } +}; + export const writeCache = (key: string, data: T): void => { + const serialized = JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload); + try { - localStorage.setItem( - `${CACHE_PREFIX}${key}`, - JSON.stringify({ - savedAt: Date.now(), - data, - } satisfies CachedPayload) - ); + localStorage.setItem(`${CACHE_PREFIX}${key}`, serialized); } catch { - // ignore quota/storage errors + // Most likely out of quota: drop expired entries and try once more. + try { + pruneExpiredCaches(); + localStorage.setItem(`${CACHE_PREFIX}${key}`, serialized); + } catch { + // ignore quota/storage errors + } } }; diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx index 0ca3bc72..213ab043 100644 --- a/src/pages/ResourcesHub.tsx +++ b/src/pages/ResourcesHub.tsx @@ -73,6 +73,8 @@ const ResourcesHub = () => { handleSearch, handleDownload, availableSubcategories, + loadError, + retryResources, } = useResources(); const inputRef = useRef(null); @@ -384,6 +386,8 @@ const ResourcesHub = () => { hasCategoryResources={hasCategoryResources} onMusicLink={onMusicLink} onAnimate={setAnimatingResource} + loadError={loadError} + onRetry={retryResources} /> )} diff --git a/workers/assets-gateway/README.md b/workers/assets-gateway/README.md index 918c2935..884dcbc7 100644 --- a/workers/assets-gateway/README.md +++ b/workers/assets-gateway/README.md @@ -25,16 +25,23 @@ The gateway only signs requests for a caller that proves it is a real renderdrag - `{ "turnstileToken": "" }` for an anonymous visitor (verified server-side against Cloudflare Turnstile, including the hostname). On success the gateway returns `Set-Cookie: rdgw=…; HttpOnly; Secure; SameSite=None` - Path=/api/assets-gw` — a short-lived HMAC-signed session. -3. Any other `/api/assets-gw/` request must carry that cookie. The gateway signs it + Path=/` — a short-lived HMAC-signed session — **and** the same token in the response body + (`{ "ok": true, "expiresAt": …, "token": "v1.…" }`). +3. Any other `/api/assets-gw/` request must carry that session, either as the `rdgw` + cookie or as `Authorization: Bearer `. The gateway signs the request (HMAC-SHA256, same canonical string as the router) with `GATEWAY_SECRET` and proxies to `ROUTER_ORIGIN`. +The bearer form exists because `rdgw` is a **third-party** cookie (`renderdragon.org` → +`gateway.codersoft.xyz`): Safari/ITP, Brave, Chrome incognito and strict Firefox/ETP drop it, +which made every catalog request 401 for those visitors. The token is only readable by origins +allowed by CORS, and it is still handed out as an `HttpOnly` cookie wherever cookies work. + `GET /api/assets-gw/__version` is **public** — no session, no challenge. The SPA caches the catalog and compares this version; it only establishes a session (step 2) when the version changed, so returning visitors are not challenged. -Spoofed `Origin`, `Referer`, or `X-Auth-*` headers cannot mint a session: the cookie requires a +Spoofed `Origin`, `Referer`, or `X-Auth-*` headers cannot mint a session: the session requires a server-verified Turnstile result (or a Supabase login), and the router independently verifies the gateway's signature. Cookies are `HttpOnly` (no JS access), `Secure`, and `SameSite=None` (cross-site; `Lax` in local dev), which also blocks CSRF to the upload endpoint. diff --git a/workers/assets-gateway/src/index.ts b/workers/assets-gateway/src/index.ts index 17e4d7fd..f2d807e7 100644 --- a/workers/assets-gateway/src/index.ts +++ b/workers/assets-gateway/src/index.ts @@ -31,6 +31,7 @@ const STRIP_FROM_REQUEST = new Set([ "content-length", "accept-encoding", "cookie", + "authorization", "origin", "referer", "cf-connecting-ip", @@ -128,7 +129,7 @@ async function verifySession(secret: string, token: string): Promise { const header = request.headers.get("Cookie") ?? ""; @@ -141,6 +142,23 @@ const getCookie = (request: Request, name: string): string | null => { return null; }; +const getBearerToken = (request: Request): string | null => { + const header = request.headers.get("Authorization") ?? ""; + const match = /^Bearer[ \t]+(\S+)$/i.exec(header.trim()); + return match ? match[1] : null; +}; + +/** + * The session normally travels as the `rdgw` cookie, but that cookie is + * third-party (`renderdragon.org` -> `gateway.codersoft.xyz`) and browsers that + * block third-party cookies (Safari/ITP, Brave, Chrome incognito, strict ETP) + * silently drop it, which used to make every catalog request 401 for those + * users. The same signed token is therefore also accepted as + * `Authorization: Bearer `. + */ +const readSessionToken = (request: Request): string | null => + getBearerToken(request) ?? getCookie(request, COOKIE_NAME); + const sessionCookie = (value: string, maxAge: number, secure: boolean): string => `${COOKIE_NAME}=${value}; Path=/; HttpOnly;${secure ? " Secure;" : ""} SameSite=${secure ? "None" : "Lax"}; Max-Age=${maxAge}`; @@ -262,11 +280,13 @@ async function establishSession(request: Request, env: Env): Promise { const token = await mintSession(env.GATEWAY_SECRET, session); const secure = (env.COOKIE_SECURE ?? "true") !== "false"; - return jsonResponse({ ok: true, expiresAt: session.exp }, 200, { token, maxAge, secure }); + // The token is returned both as the cookie above and in the body: the body copy + // is what clients use when the third-party cookie cannot be stored. + return jsonResponse({ ok: true, expiresAt: session.exp, token }, 200, { token, maxAge, secure }); } async function sessionStatus(request: Request, env: Env): Promise { - const token = getCookie(request, COOKIE_NAME); + const token = readSessionToken(request); const session = token ? await verifySession(env.GATEWAY_SECRET, token) : null; if (!session) return jsonResponse({ ok: false }, 401); return jsonResponse({ ok: true, expiresAt: session.exp }, 200); @@ -276,7 +296,7 @@ async function proxy(request: Request, env: Env, url: URL, relativePath: string) const origin = request.headers.get("Origin"); if (origin && !originAllowed(origin, env)) return jsonResponse({ error: "origin_not_allowed" }, 403); - const token = getCookie(request, COOKIE_NAME); + const token = readSessionToken(request); const session = token ? await verifySession(env.GATEWAY_SECRET, token) : null; if (!session) return jsonResponse({ error: "session_required" }, 401);