diff --git a/OwnTube.tv/api/axiosInstance.ts b/OwnTube.tv/api/axiosInstance.ts index e55cd6b9..eb178203 100644 --- a/OwnTube.tv/api/axiosInstance.ts +++ b/OwnTube.tv/api/axiosInstance.ts @@ -7,6 +7,10 @@ import { postHogInstance } from "../diagnostics"; import { CustomPostHogEvents, CustomPostHogExceptions } from "../diagnostics/constants"; import { APP_IDENTIFIER } from "./sharedConstants"; +// Refresh tokens at a specified percentage of their lifetime to maximize login persistence +// for intermittent users (users who visit every few weeks) +const TOKEN_REFRESH_LIFETIME_PERCENTAGE = 0.5; + export const axiosInstance = axios.create({ withCredentials: false, headers: { @@ -19,6 +23,17 @@ const controller = new AbortController(); const REFRESH_LOCKS: Record = {}; +/** + * Refreshes an access token using a refresh token. + * + * Uses a backend-specific lock to prevent concurrent refresh attempts. + * Typical use case: Proactively refresh tokens before they expire to keep + * intermittent users logged in (users who visit every few weeks). + * + * @param backend - The backend server hostname + * @param refreshToken - The refresh token to use + * @returns The new login response with fresh tokens, or null if locked + */ const refreshAccessToken = async (backend: string, refreshToken: string) => { const lock = REFRESH_LOCKS[backend]; @@ -60,7 +75,7 @@ axiosInstance.interceptors.request.use(async (config) => { const { session, updateSession } = useAuthSessionStore.getState(); - if (!backend || !session) return config; + if (!backend) return config; const { basePath, @@ -72,16 +87,21 @@ axiosInstance.interceptors.request.use(async (config) => { refreshTokenIssuedAt, refreshTokenExpiresIn, sessionExpired, - } = session; + } = session || {}; const now = Math.floor(Date.now() / 1000); - const accessIssued = parseISOToEpoch(accessTokenIssuedAt); - const accessValidUntil = accessIssued + accessTokenExpiresIn - 10; - const accessTokenValid = accessIssued <= now && now < accessValidUntil; - const refreshIssued = parseISOToEpoch(refreshTokenIssuedAt); - const refreshValidUntil = refreshIssued + refreshTokenExpiresIn - 10; - const refreshTokenValid = refreshIssued <= now && now < refreshValidUntil; + // Normalize issuedAt timestamps and expiresIn values to safe numbers. + const accessIssued = accessTokenIssuedAt ? parseISOToEpoch(accessTokenIssuedAt) : 0; + const accessExpiresInNum = Number(accessTokenExpiresIn ?? 0); + const accessValidUntil = accessIssued && accessExpiresInNum ? accessIssued + accessExpiresInNum - 10 : 0; + const accessTokenValid = accessIssued > 0 && accessExpiresInNum > 0 && accessIssued <= now && now < accessValidUntil; + + const refreshIssued = refreshTokenIssuedAt ? parseISOToEpoch(refreshTokenIssuedAt) : 0; + const refreshExpiresInNum = Number(refreshTokenExpiresIn ?? 0); + const refreshValidUntil = refreshIssued && refreshExpiresInNum ? refreshIssued + refreshExpiresInNum - 10 : 0; + const refreshTokenValid = + refreshIssued > 0 && refreshExpiresInNum > 0 && refreshIssued <= now && now < refreshValidUntil; const shouldAttachAccessToken = Boolean( session && @@ -95,9 +115,18 @@ axiosInstance.interceptors.request.use(async (config) => { config.headers.Authorization = `${tokenType} ${accessToken}`; } - const halfway = accessIssued + accessTokenExpiresIn * 0.5; - if ((now > halfway && refreshToken && shouldAttachAccessToken) || (!accessTokenValid && refreshTokenValid)) { + // Proactively refresh tokens at a specified percentage of their lifetime to maximize login + // persistence for intermittent users. For a typical 2-week token lifetime, + // this refreshes after ~1 week if set to 50%, ensuring the refresh token itself gets + // renewed before it expires. + const proactiveRefreshThreshold = + accessIssued && accessExpiresInNum ? accessIssued + accessExpiresInNum * TOKEN_REFRESH_LIFETIME_PERCENTAGE : 0; + if ( + (now > proactiveRefreshThreshold && refreshToken && shouldAttachAccessToken) || + (!accessTokenValid && refreshTokenValid) + ) { try { + if (!refreshToken) throw new Error("Missing refresh token"); const refreshed = await refreshAccessToken(backend, refreshToken); if (refreshed) { const parsed = parseAuthSessionData(refreshed, backend); @@ -111,7 +140,7 @@ axiosInstance.interceptors.request.use(async (config) => { } } - if (!accessTokenValid && !refreshTokenValid) { + if (!accessTokenValid && !refreshTokenValid && session) { await useAuthSessionStore.getState().updateSession(backend, { sessionExpired: true }); controller.abort("Session expired, aborting request"); postHogInstance.capture(CustomPostHogEvents.SessionExpired); @@ -121,6 +150,31 @@ axiosInstance.interceptors.request.use(async (config) => { return config; }); +// Capture 401 and prompt the user to login again +axiosInstance.interceptors.response.use( + (resp) => resp, + async (error) => { + try { + const status = error?.response?.status; + if (status === 401) { + const config = error?.config || {}; + const backend = config.baseURL?.replace("/api/v1", "").replace("https://", ""); + + if (typeof backend === "string" && backend.length > 0) { + console.info("Session expired, aborting request"); + await useAuthSessionStore.getState().updateSession(backend, { sessionExpired: true }); + } + + postHogInstance.capture(CustomPostHogEvents.SessionExpired); + } + } catch (e) { + console.error("Stale token handling frontend error:", e); + } + + return Promise.reject(error); + }, +); + export abstract class AxiosInstanceBasedApi { protected constructor(debugLogging: boolean = false) { this.attachToAxiosInstance(); diff --git a/OwnTube.tv/api/errorHandler.ts b/OwnTube.tv/api/errorHandler.ts index 828aebb0..591f5df6 100644 --- a/OwnTube.tv/api/errorHandler.ts +++ b/OwnTube.tv/api/errorHandler.ts @@ -2,6 +2,7 @@ import { AxiosError } from "axios"; import { OwnTubeError } from "./models"; import { postHogInstance } from "../diagnostics"; import { CustomPostHogExceptions } from "../diagnostics/constants"; +import { parseAxiosErrorDiagnosticsData } from "./helpers"; export function handleAxiosErrorWithRetry(error: unknown, target: string): Promise { const { message, response } = error as AxiosError; @@ -9,9 +10,15 @@ export function handleAxiosErrorWithRetry(error: unknown, target: string): Promi if (retryAfter) { console.info(`Too many requests. Retrying to fetch ${target} in ${retryAfter} seconds...`); - postHogInstance.captureException(error, { errorType: `${CustomPostHogExceptions.RateLimitError} (${target})` }); + postHogInstance.captureException(error, { + errorType: `${CustomPostHogExceptions.RateLimitError} (${target})`, + originalError: parseAxiosErrorDiagnosticsData(error as AxiosError), + }); } else { - postHogInstance.captureException(error, { errorType: `${CustomPostHogExceptions.HttpRequestError} (${target})` }); + postHogInstance.captureException(error, { + errorType: `${CustomPostHogExceptions.HttpRequestError} (${target})`, + originalError: parseAxiosErrorDiagnosticsData(error as AxiosError), + }); } return new Promise((_, reject) => { diff --git a/OwnTube.tv/api/helpers.ts b/OwnTube.tv/api/helpers.ts index ceceab7b..d90ddfda 100644 --- a/OwnTube.tv/api/helpers.ts +++ b/OwnTube.tv/api/helpers.ts @@ -1,6 +1,8 @@ import { GetVideosVideo, OwnTubeError } from "./models"; import { QUERY_KEYS } from "./constants"; import { UseQueryResult } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import { JsonType } from "posthog-react-native/lib/posthog-core/src"; import { Video } from "@peertube/peertube-types"; const jsonPaths: Record = { @@ -35,8 +37,23 @@ export const combineCollectionQueryResults = ( ) => { return { data: result.filter((item) => item?.data?.isError || Number(item?.data?.total) > 0), - isLoading: result.filter(({ isLoading }) => isLoading).length > 1, + isLoading: result.some(({ isLoading }) => isLoading), isError: result.length > 0 && result.every(({ data }) => data?.isError), + error: (result.filter(({ data }) => data?.isError)?.map(({ data }) => data?.error) as OwnTubeError[]) || null, + }; +}; + +export const parseAxiosErrorDiagnosticsData = (error?: AxiosError): JsonType => { + const requestUrl = + error?.config?.baseURL && error?.config?.url ? new URL(error.config.url, error.config.baseURL).toString() : null; + + return { + code: error?.code || null, + requestUrl, + method: error?.config?.method || null, + params: error?.config?.params || null, + timeout: error?.config?.timeout || null, + status: error?.status || null, }; }; diff --git a/OwnTube.tv/app/_layout.tsx b/OwnTube.tv/app/_layout.tsx index 18d16c69..c61902ff 100644 --- a/OwnTube.tv/app/_layout.tsx +++ b/OwnTube.tv/app/_layout.tsx @@ -104,58 +104,55 @@ const RootStack = () => { <> - - renderAppHeader(props), - }} - backBehavior="history" - drawerContent={(props) => } - > - <> }} - /> - - <> }} - /> - - - - - - - - - - - - - , - }} + renderAppHeader(props), + }} + backBehavior="history" + drawerContent={(props) => } + > + <> }} /> - { - handleModalClose?.(); - toggleModal?.(false); - }} - isVisible={isModalOpen} - > - {modalContent} - - + + <> }} + /> + + + + + + + + + + + + + , + }} + /> + { + handleModalClose?.(); + toggleModal?.(false); + }} + isVisible={isModalOpen} + > + {modalContent} + ); @@ -192,18 +189,20 @@ export default function RootLayout() { return ( - - - - {isWeb && } - - - - - - - - + + + + + {isWeb && } + + + + + + + + + ); diff --git a/OwnTube.tv/components/CategoryView.tsx b/OwnTube.tv/components/CategoryView.tsx index 4a544061..807bb581 100644 --- a/OwnTube.tv/components/CategoryView.tsx +++ b/OwnTube.tv/components/CategoryView.tsx @@ -26,13 +26,14 @@ export const CategoryView = ({ category }: CategoryViewProps) => { const { currentInstanceConfig } = useAppConfigContext(); const showHorizontalScrollableLists = currentInstanceConfig?.customizations?.homeUseHorizontalListsForMobilePortrait; - if (!data?.data?.length && !isLoading) { + if (!data?.data?.length && !isLoading && !isError) { return null; } return ( <> { const { currentInstanceConfig } = useAppConfigContext(); const showHorizontalScrollableLists = currentInstanceConfig?.customizations?.homeUseHorizontalListsForMobilePortrait; - if (!data?.data?.length && !isLoading) { + if (!data?.data?.length && !isLoading && !isError) { return null; } diff --git a/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx b/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx index 4f43ab90..34893c96 100644 --- a/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx +++ b/OwnTube.tv/components/DeviceCapabilities/DeviceCapabilities.tsx @@ -1,17 +1,17 @@ -import { Pressable, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { Typography } from "../Typography"; import * as Clipboard from "expo-clipboard"; import { useAppConfigContext } from "../../contexts"; import { useTheme } from "@react-navigation/native"; import { useTranslation } from "react-i18next"; -import { IcoMoonIcon } from "../IcoMoonIcon"; -import { borderRadius } from "../../theme"; +import { spacing } from "../../theme"; import { BuildInfo } from "../BuildInfo"; import build_info from "../../build-info.json"; import { useAuthSessionStore } from "../../store"; import { format } from "date-fns"; -import { useMemo } from "react"; +import { useMemo, useRef, useState, useEffect } from "react"; import { useGlobalSearchParams } from "expo-router"; +import { Button } from "../shared"; const CapabilityKeyValuePair = ({ label, value }: { label: string; value: string }) => { const { colors } = useTheme(); @@ -54,7 +54,38 @@ const DeviceCapabilities = () => { [session], ); + const timeoutRef = useRef | null>(null); + const [copyButtonText, setCopyButtonText] = useState(undefined); + const pressCountRef = useRef(0); + const pressResetRef = useRef | null>(null); + + const [shouldThrow, setShouldThrow] = useState(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (pressResetRef.current) clearTimeout(pressResetRef.current); + }; + }, []); + const handleCopyToClipboard = async () => { + pressCountRef.current = (pressCountRef.current || 0) + 1; + if (pressCountRef.current >= 5) { + pressCountRef.current = 0; + if (pressResetRef.current) { + clearTimeout(pressResetRef.current); + pressResetRef.current = null; + } + + setShouldThrow(new Error(t("pressedTooManyTimesError"))); + return; + } + if (pressResetRef.current) clearTimeout(pressResetRef.current); + pressResetRef.current = setTimeout(() => { + pressCountRef.current = 0; + pressResetRef.current = null; + }, 2000); + const buildInfo = process.env.EXPO_PUBLIC_HIDE_GIT_DETAILS ? { BUILD_TIMESTAMP: build_info.BUILD_TIMESTAMP } : build_info; @@ -70,6 +101,11 @@ const DeviceCapabilities = () => { }, }), ); + setCopyButtonText(t("copied")); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setCopyButtonText(undefined); + }, 3_000); }; const currentAuthText = useMemo(() => { @@ -86,15 +122,25 @@ const DeviceCapabilities = () => { }); }, [authInfo, t]); + if (shouldThrow) { + // This throw happens during render and will be caught by your ErrorBoundary + throw shouldThrow; + } + return ( {t("settingsPageDeviceCapabilityInfoHeading")} - - - +