mirror of https://github.com/buster-so/buster.git
commit
4e7e8f0f0e
|
@ -10,6 +10,15 @@ const apiUrl = new URL(env.NEXT_PUBLIC_API_URL).origin;
|
|||
const api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin;
|
||||
const profilePictureURL = 'https://googleusercontent.com';
|
||||
|
||||
// Derive Supabase origins (HTTP and WS) from env so CSP allows them in all modes
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseOrigin = supabaseUrl ? new URL(supabaseUrl).origin : '';
|
||||
const supabaseWsOrigin = supabaseUrl
|
||||
? supabaseUrl.startsWith('https')
|
||||
? supabaseOrigin.replace('https', 'wss')
|
||||
: supabaseOrigin.replace('http', 'ws')
|
||||
: '';
|
||||
|
||||
// Function to create CSP header with dynamic API URLs
|
||||
const createCspHeader = (isEmbed = false) => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
@ -42,6 +51,8 @@ const createCspHeader = (isEmbed = false) => {
|
|||
"'self'",
|
||||
'data:', // Allow data URLs for PDF exports and other data URI downloads
|
||||
localDomains,
|
||||
supabaseOrigin,
|
||||
supabaseWsOrigin,
|
||||
'https://*.vercel.app',
|
||||
'https://*.supabase.co',
|
||||
'wss://*.supabase.co',
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
'use server';
|
||||
|
||||
import type { RequestInit } from 'next/dist/server/web/spec-extension/request';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { BASE_URL } from './buster_rest/config';
|
||||
import type { RustApiError } from './buster_rest/errors';
|
||||
|
@ -64,8 +63,7 @@ export const serverFetch = async <T>(url: string, config: FetchConfig = {}): Pro
|
|||
};
|
||||
|
||||
export const getSupabaseTokenFromCookies = async () => {
|
||||
const cookiesManager = await cookies();
|
||||
const tokenCookie =
|
||||
cookiesManager.get('sb-127-auth-token') || cookiesManager.get('next-sb-access-token');
|
||||
return tokenCookie?.value || '';
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const sessionData = await supabase.auth.getSession();
|
||||
return sessionData.data?.session?.access_token || '';
|
||||
};
|
||||
|
|
|
@ -20,54 +20,114 @@ const useSupabaseContextInternal = ({
|
|||
supabaseContext: UseSupabaseUserContextType;
|
||||
}) => {
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const { openErrorNotification, openInfoMessage } = useBusterNotifications();
|
||||
const { openErrorNotification } = useBusterNotifications();
|
||||
const [accessToken, setAccessToken] = useState(supabaseContext.accessToken || '');
|
||||
const refreshInFlightRef = useRef<Promise<string> | null>(null);
|
||||
|
||||
const isAnonymousUser: boolean =
|
||||
!supabaseContext.user?.id || supabaseContext.user?.is_anonymous === true;
|
||||
|
||||
const getExpiresAt = useMemoizedFn((token?: string) => {
|
||||
const decoded = jwtDecode(token || accessToken);
|
||||
const expiresAtDecoded = decoded?.exp || 0;
|
||||
const ms = millisecondsFromUnixTimestamp(expiresAtDecoded);
|
||||
return ms;
|
||||
try {
|
||||
const decoded = jwtDecode(token || accessToken);
|
||||
const expiresAtDecoded = (decoded as { exp?: number } | undefined)?.exp ?? 0;
|
||||
const expiresAtMs = millisecondsFromUnixTimestamp(expiresAtDecoded);
|
||||
const msUntilExpiry = Math.max(0, expiresAtMs - Date.now());
|
||||
return msUntilExpiry;
|
||||
} catch {
|
||||
// If token is missing/invalid, report that it is effectively expired now
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const onUpdateToken = useMemoizedFn(
|
||||
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
|
||||
setAccessToken(accessToken);
|
||||
flushSync(() => {
|
||||
//noop
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const checkTokenValidity = useMemoizedFn(async () => {
|
||||
try {
|
||||
const ms = getExpiresAt();
|
||||
const minutesUntilExpiration = ms / 60000;
|
||||
const isTokenExpired = minutesUntilExpiration < PREEMTIVE_REFRESH_MINUTES; //5 minutes
|
||||
|
||||
// Anonymous users do not require refresh; return current token as-is
|
||||
if (isAnonymousUser) {
|
||||
return {
|
||||
access_token: accessToken,
|
||||
isTokenValid: isTokenExpired
|
||||
isTokenValid: true
|
||||
};
|
||||
}
|
||||
|
||||
if (isTokenExpired) {
|
||||
const { data: refreshedSession, error: refreshedSessionError } =
|
||||
await supabase.auth.refreshSession();
|
||||
// If we don't have a token in memory, try to recover it from Supabase session
|
||||
if (!accessToken) {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const recoveredToken = sessionData.session?.access_token || '';
|
||||
if (recoveredToken) {
|
||||
await onUpdateToken({
|
||||
accessToken: recoveredToken,
|
||||
expiresAt: sessionData.session?.expires_at ?? 0
|
||||
});
|
||||
return {
|
||||
access_token: recoveredToken,
|
||||
isTokenValid: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshedSessionError || !refreshedSession.session) {
|
||||
const msUntilExpiration = getExpiresAt();
|
||||
const minutesUntilExpiration = msUntilExpiration / 60000;
|
||||
const needsPreemptiveRefresh = minutesUntilExpiration < PREEMTIVE_REFRESH_MINUTES;
|
||||
|
||||
if (needsPreemptiveRefresh) {
|
||||
try {
|
||||
// Ensure only one refresh is in-flight
|
||||
let refreshPromise = refreshInFlightRef.current;
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const { data: refreshedSession, error: refreshedSessionError } =
|
||||
await supabase.auth.refreshSession();
|
||||
|
||||
if (refreshedSessionError || !refreshedSession.session) {
|
||||
throw refreshedSessionError || new Error('Failed to refresh session');
|
||||
}
|
||||
|
||||
const refreshedAccessToken = refreshedSession.session.access_token;
|
||||
const expiresAt = refreshedSession.session.expires_at ?? 0;
|
||||
await onUpdateToken({ accessToken: refreshedAccessToken, expiresAt });
|
||||
return refreshedAccessToken;
|
||||
})();
|
||||
refreshInFlightRef.current = refreshPromise;
|
||||
// Clear the ref when the refresh resolves/rejects
|
||||
void refreshPromise.finally(() => {
|
||||
refreshInFlightRef.current = null;
|
||||
});
|
||||
}
|
||||
|
||||
const refreshedAccessToken = await refreshPromise;
|
||||
await timeout(25);
|
||||
return {
|
||||
access_token: refreshedAccessToken,
|
||||
isTokenValid: true
|
||||
};
|
||||
} catch (err) {
|
||||
openErrorNotification({
|
||||
title: 'Error refreshing session',
|
||||
description: 'Please refresh the page and try again',
|
||||
duration: 120 * 1000 //2 minutes
|
||||
});
|
||||
throw refreshedSessionError;
|
||||
// As a fallback, try to read whatever session is available
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const fallbackToken = sessionData.session?.access_token || '';
|
||||
if (fallbackToken) {
|
||||
await onUpdateToken({
|
||||
accessToken: fallbackToken,
|
||||
expiresAt: sessionData.session?.expires_at ?? 0
|
||||
});
|
||||
return { access_token: fallbackToken, isTokenValid: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const accessToken = refreshedSession.session?.access_token;
|
||||
const expiresAt = refreshedSession.session?.expires_at ?? 0;
|
||||
|
||||
await onUpdateToken({ accessToken, expiresAt });
|
||||
await timeout(25);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
isTokenValid: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -85,15 +145,6 @@ const useSupabaseContextInternal = ({
|
|||
}
|
||||
});
|
||||
|
||||
const onUpdateToken = useMemoizedFn(
|
||||
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
|
||||
setAccessToken(accessToken);
|
||||
flushSync(() => {
|
||||
//noop
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (supabaseContext.accessToken) {
|
||||
setAccessToken(supabaseContext.accessToken);
|
||||
|
@ -106,6 +157,10 @@ const useSupabaseContextInternal = ({
|
|||
const refreshBuffer = PREEMTIVE_REFRESH_MINUTES * 60000; // Refresh minutes before expiration
|
||||
const timeUntilRefresh = Math.max(0, expiresInMs - refreshBuffer);
|
||||
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
|
||||
// Set up timer for future refresh
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
checkTokenValidity();
|
||||
|
@ -120,7 +175,45 @@ const useSupabaseContextInternal = ({
|
|||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [accessToken, checkTokenValidity]);
|
||||
}, [accessToken, checkTokenValidity, getExpiresAt]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep access token in sync with Supabase client (captures auto-refresh events)
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
const newToken = session?.access_token;
|
||||
if (event === 'SIGNED_OUT' || !newToken) {
|
||||
setAccessToken('');
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
void onUpdateToken({ accessToken: newToken, expiresAt: session?.expires_at ?? 0 });
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.subscription.unsubscribe();
|
||||
};
|
||||
}, [onUpdateToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void checkTokenValidity();
|
||||
}
|
||||
};
|
||||
const onOnline = () => {
|
||||
void checkTokenValidity();
|
||||
};
|
||||
|
||||
window.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('online', onOnline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('online', onOnline);
|
||||
};
|
||||
}, [checkTokenValidity]);
|
||||
|
||||
return {
|
||||
isAnonymousUser,
|
||||
|
|
|
@ -61,14 +61,14 @@ vi.mock('remark-gfm', () => ({
|
|||
}));
|
||||
|
||||
// Mock Supabase client to prevent environment variable errors in tests
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
createBrowserClient: vi.fn(() => ({
|
||||
vi.mock('@/lib/supabase/client', () => {
|
||||
const mockClient = {
|
||||
auth: {
|
||||
refreshSession: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
session: {
|
||||
access_token: 'mock-token',
|
||||
expires_at: Date.now() / 1000 + 3600 // 1 hour from now
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now
|
||||
}
|
||||
},
|
||||
error: null
|
||||
|
@ -76,7 +76,24 @@ vi.mock('@/lib/supabase/client', () => ({
|
|||
getUser: vi.fn().mockResolvedValue({
|
||||
data: { user: null },
|
||||
error: null
|
||||
}),
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
session: {
|
||||
access_token: 'mock-token',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600
|
||||
}
|
||||
},
|
||||
error: null
|
||||
}),
|
||||
onAuthStateChange: vi.fn().mockReturnValue({
|
||||
data: { subscription: { unsubscribe: vi.fn() } }
|
||||
})
|
||||
}
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
createBrowserClient: vi.fn(() => mockClient),
|
||||
getBrowserClient: vi.fn(() => mockClient)
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue