diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index f10c1c4..9fc14a9 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -1,5 +1,10 @@ -import axios from "axios"; -import { API_BASE_URL } from "../config"; +import axios from "axios"; +import { API_BASE_URL } from "../config"; +import { + cacheApiResponse, + getCachedApiResponse, + isTransientApiError, +} from "./offlineCache"; const api = axios.create({ baseURL: API_BASE_URL, @@ -31,6 +36,7 @@ api.interceptors.response.use( response.request_id = payload.request_id; response.data = payload.data; } + cacheApiResponse(response.config, response.data); return response; }, error => { @@ -55,8 +61,25 @@ api.interceptors.response.use( window.location.href = "/login"; alert("Your session has expired. Please log in again."); } - return Promise.reject(error); - } -); + + if (isTransientApiError(error)) { + const cached = getCachedApiResponse(error.config); + if (cached) { + return Promise.resolve({ + data: cached.data, + status: 200, + statusText: "OK (stale cache)", + headers: {}, + config: error.config, + request: error.request, + stale: true, + cachedAt: cached.cachedAt, + }); + } + } + + return Promise.reject(error); + } +); export default api; diff --git a/frontend/src/api/offlineCache.js b/frontend/src/api/offlineCache.js new file mode 100644 index 0000000..0df476f --- /dev/null +++ b/frontend/src/api/offlineCache.js @@ -0,0 +1,186 @@ +const CACHE_PREFIX = "fiddy:api-cache:v1:"; +const CACHE_INDEX_KEY = "fiddy:api-cache-index:v1"; +const MAX_CACHE_ENTRIES = 80; +const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]); +const SENSITIVE_URL_PATTERN = /(receipt|download|update-image)/i; +const BINARY_FIELD_PATTERN = /(image|receipt|bytes|buffer|blob|base64)/i; + +function getStorage() { + if (typeof window === "undefined" || !window.localStorage) return null; + return window.localStorage; +} + +function getCurrentUserScope() { + const storage = getStorage(); + if (!storage) return null; + + try { + return storage.getItem("userId") || null; + } catch (_) { + return null; + } +} + +function stableStringify(value) { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; +} + +function hashString(value) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash << 5) - hash + value.charCodeAt(index); + hash |= 0; + } + return Math.abs(hash).toString(36); +} + +function requestFingerprint(config) { + if (!config || String(config.method || "get").toLowerCase() !== "get") { + return null; + } + + if (config.responseType && config.responseType !== "json") { + return null; + } + + const url = config.url || ""; + if (!url || SENSITIVE_URL_PATTERN.test(url)) { + return null; + } + + return stableStringify({ + baseURL: config.baseURL || "", + method: "get", + params: config.params || null, + url, + }); +} + +function cacheKeyForConfig(config) { + const scope = getCurrentUserScope(); + const fingerprint = requestFingerprint(config); + if (!scope || !fingerprint) return null; + + return `${CACHE_PREFIX}${scope}:${hashString(fingerprint)}`; +} + +function readIndex(storage) { + try { + const parsed = JSON.parse(storage.getItem(CACHE_INDEX_KEY) || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + return []; + } +} + +function writeIndex(storage, index) { + try { + storage.setItem(CACHE_INDEX_KEY, JSON.stringify(index.slice(-MAX_CACHE_ENTRIES))); + } catch (_) { + // Ignore cache index write failures; the live request already succeeded. + } +} + +function rememberCacheKey(storage, key, scope) { + const now = Date.now(); + const nextIndex = readIndex(storage) + .filter((entry) => entry?.key !== key) + .concat({ key, scope, touchedAt: now }); + + const trimmedIndex = nextIndex.slice(-MAX_CACHE_ENTRIES); + for (const staleEntry of nextIndex.slice(0, -MAX_CACHE_ENTRIES)) { + try { + storage.removeItem(staleEntry.key); + } catch (_) { + // Best-effort cache pruning only. + } + } + writeIndex(storage, trimmedIndex); +} + +function sanitizeForCache(value) { + if (Array.isArray(value)) { + return value.map(sanitizeForCache); + } + + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => [ + key, + BINARY_FIELD_PATTERN.test(key) ? null : sanitizeForCache(entryValue), + ]) + ); +} + +export function cacheApiResponse(config, data) { + const storage = getStorage(); + const key = cacheKeyForConfig(config); + const scope = getCurrentUserScope(); + if (!storage || !key || !scope) return; + + try { + storage.setItem( + key, + JSON.stringify({ + cachedAt: new Date().toISOString(), + data: sanitizeForCache(data), + }) + ); + rememberCacheKey(storage, key, scope); + } catch (_) { + // Cache is an opportunistic fallback. Quota/private-mode failures are non-fatal. + } +} + +export function getCachedApiResponse(config) { + const storage = getStorage(); + const key = cacheKeyForConfig(config); + if (!storage || !key) return null; + + try { + const cached = JSON.parse(storage.getItem(key) || "null"); + if (!cached || !Object.prototype.hasOwnProperty.call(cached, "data")) { + return null; + } + return cached; + } catch (_) { + return null; + } +} + +export function isTransientApiError(error) { + if (!error?.response) return true; + return TRANSIENT_STATUS_CODES.has(error.response.status); +} + +export function clearApiCacheForCurrentUser() { + const storage = getStorage(); + const scope = getCurrentUserScope(); + if (!storage || !scope) return; + + const nextIndex = readIndex(storage).filter((entry) => { + if (entry?.scope !== scope) return true; + try { + storage.removeItem(entry.key); + } catch (_) { + // Best effort only. + } + return false; + }); + + writeIndex(storage, nextIndex); +} diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index ca3627c..6835930 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -1,4 +1,5 @@ -import { createContext, useState } from 'react'; +import { createContext, useState } from 'react'; +import { clearApiCacheForCurrentUser } from '../api/offlineCache'; export const AuthContext = createContext({ token: null, @@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => { const [username, setUsername] = useState(localStorage.getItem('username') || null); const clearAuthStorage = () => { + clearApiCacheForCurrentUser(); localStorage.removeItem("token"); localStorage.removeItem("userId"); localStorage.removeItem("role");