Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 65 additions & 11 deletions OwnTube.tv/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -19,6 +23,17 @@ const controller = new AbortController();

const REFRESH_LOCKS: Record<string, boolean> = {};

/**
* 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];

Expand Down Expand Up @@ -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,
Expand All @@ -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 &&
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions OwnTube.tv/api/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ 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<never> {
const { message, response } = error as AxiosError;
const retryAfter = response?.headers["retry-after"];

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) => {
Expand Down
19 changes: 18 additions & 1 deletion OwnTube.tv/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<keyof typeof QUERY_KEYS, string> = {
Expand Down Expand Up @@ -35,8 +37,23 @@ export const combineCollectionQueryResults = <T>(
) => {
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,
};
};

Expand Down
125 changes: 62 additions & 63 deletions OwnTube.tv/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,58 +104,55 @@ const RootStack = () => {
<>
<StatusBar style={scheme === "dark" ? "light" : "dark"} />
<ThemeProvider value={theme}>
<ErrorBoundary>
<Drawer
screenOptions={{
drawerType: breakpoints.isMobile ? "front" : "permanent",
drawerStyle: {
display: !backend ? "none" : "flex",
width:
(!breakpoints.isDesktop && !breakpoints.isMobile ? CLOSED_DRAWER_WIDTH : OPEN_DRAWER_WIDTH) + left,
borderRightWidth: 0,
},
header: (props) => renderAppHeader(props),
}}
backBehavior="history"
drawerContent={(props) => <Sidebar {...props} backend={backend} />}
>
<Drawer.Screen
name={"(home)/index"}
options={{ drawerStyle: { display: "none" }, swipeEnabled: false, header: () => <></> }}
/>
<Drawer.Screen name={"(home)/home"} />
<Drawer.Screen
name={`(home)/video`}
options={{ drawerStyle: { display: "none" }, swipeEnabled: false, header: () => <></> }}
/>
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNELS}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL_CATEGORY}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL_PLAYLIST}`} />
<Drawer.Screen name={`(home)/${ROUTES.CATEGORIES}`} />
<Drawer.Screen name={`(home)/${ROUTES.CATEGORY}`} />
<Drawer.Screen name={`(home)/${ROUTES.PLAYLISTS}`} />
<Drawer.Screen name={`(home)/${ROUTES.PLAYLIST}`} />
<Drawer.Screen name={`(home)/${ROUTES.SIGNIN}`} />
<Drawer.Screen name={`(home)/${ROUTES.OTP}`} />
<Drawer.Screen name={`(home)/${ROUTES.SEARCH}`} />
</Drawer>
<Toast
topOffset={top || undefined}
config={{
info: (props) => <InfoToast {...props} />,
}}
<Drawer
screenOptions={{
drawerType: breakpoints.isMobile ? "front" : "permanent",
drawerStyle: {
display: !backend ? "none" : "flex",
width: (!breakpoints.isDesktop && !breakpoints.isMobile ? CLOSED_DRAWER_WIDTH : OPEN_DRAWER_WIDTH) + left,
borderRightWidth: 0,
},
header: (props) => renderAppHeader(props),
}}
backBehavior="history"
drawerContent={(props) => <Sidebar {...props} backend={backend} />}
>
<Drawer.Screen
name={"(home)/index"}
options={{ drawerStyle: { display: "none" }, swipeEnabled: false, header: () => <></> }}
/>
<FullScreenModal
onBackdropPress={() => {
handleModalClose?.();
toggleModal?.(false);
}}
isVisible={isModalOpen}
>
{modalContent}
</FullScreenModal>
</ErrorBoundary>
<Drawer.Screen name={"(home)/home"} />
<Drawer.Screen
name={`(home)/video`}
options={{ drawerStyle: { display: "none" }, swipeEnabled: false, header: () => <></> }}
/>
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNELS}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL_CATEGORY}`} />
<Drawer.Screen name={`(home)/${ROUTES.CHANNEL_PLAYLIST}`} />
<Drawer.Screen name={`(home)/${ROUTES.CATEGORIES}`} />
<Drawer.Screen name={`(home)/${ROUTES.CATEGORY}`} />
<Drawer.Screen name={`(home)/${ROUTES.PLAYLISTS}`} />
<Drawer.Screen name={`(home)/${ROUTES.PLAYLIST}`} />
<Drawer.Screen name={`(home)/${ROUTES.SIGNIN}`} />
<Drawer.Screen name={`(home)/${ROUTES.OTP}`} />
<Drawer.Screen name={`(home)/${ROUTES.SEARCH}`} />
</Drawer>
<Toast
topOffset={top || undefined}
config={{
info: (props) => <InfoToast {...props} />,
}}
/>
<FullScreenModal
onBackdropPress={() => {
handleModalClose?.();
toggleModal?.(false);
}}
isVisible={isModalOpen}
>
{modalContent}
</FullScreenModal>
</ThemeProvider>
</>
);
Expand Down Expand Up @@ -192,18 +189,20 @@ export default function RootLayout() {
return (
<SafeAreaProvider>
<GestureHandlerRootView>
<PostHogProvider client={postHogInstance} autocapture={{ captureScreens: false }}>
<QueryClientProvider client={queryClient}>
<AppConfigContextProvider>
{isWeb && <ReactQueryDevtools initialIsOpen={false} />}
<ColorSchemeContextProvider>
<FullScreenModalContextProvider>
<RootStack />
</FullScreenModalContextProvider>
</ColorSchemeContextProvider>
</AppConfigContextProvider>
</QueryClientProvider>
</PostHogProvider>
<ErrorBoundary>
<PostHogProvider client={postHogInstance} autocapture={{ captureScreens: false }}>
<QueryClientProvider client={queryClient}>
<AppConfigContextProvider>
{isWeb && <ReactQueryDevtools initialIsOpen={false} />}
<ColorSchemeContextProvider>
<FullScreenModalContextProvider>
<RootStack />
</FullScreenModalContextProvider>
</ColorSchemeContextProvider>
</AppConfigContextProvider>
</QueryClientProvider>
</PostHogProvider>
</ErrorBoundary>
</GestureHandlerRootView>
</SafeAreaProvider>
);
Expand Down
3 changes: 2 additions & 1 deletion OwnTube.tv/components/CategoryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<VideoGrid
variant="category"
scrollable={showHorizontalScrollableLists}
reduceHeaderContrast
refetch={refetch}
Expand Down
2 changes: 1 addition & 1 deletion OwnTube.tv/components/ChannelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const ChannelView = ({ channel }: ChannelViewProps) => {
const { currentInstanceConfig } = useAppConfigContext();
const showHorizontalScrollableLists = currentInstanceConfig?.customizations?.homeUseHorizontalListsForMobilePortrait;

if (!data?.data?.length && !isLoading) {
if (!data?.data?.length && !isLoading && !isError) {
return null;
}

Expand Down
Loading