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 api2Url = new URL(env.NEXT_PUBLIC_API2_URL).origin;
|
||||||
const profilePictureURL = 'https://googleusercontent.com';
|
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
|
// Function to create CSP header with dynamic API URLs
|
||||||
const createCspHeader = (isEmbed = false) => {
|
const createCspHeader = (isEmbed = false) => {
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
@ -42,6 +51,8 @@ const createCspHeader = (isEmbed = false) => {
|
||||||
"'self'",
|
"'self'",
|
||||||
'data:', // Allow data URLs for PDF exports and other data URI downloads
|
'data:', // Allow data URLs for PDF exports and other data URI downloads
|
||||||
localDomains,
|
localDomains,
|
||||||
|
supabaseOrigin,
|
||||||
|
supabaseWsOrigin,
|
||||||
'https://*.vercel.app',
|
'https://*.vercel.app',
|
||||||
'https://*.supabase.co',
|
'https://*.supabase.co',
|
||||||
'wss://*.supabase.co',
|
'wss://*.supabase.co',
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import type { RequestInit } from 'next/dist/server/web/spec-extension/request';
|
import type { RequestInit } from 'next/dist/server/web/spec-extension/request';
|
||||||
import { cookies } from 'next/headers';
|
|
||||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||||
import { BASE_URL } from './buster_rest/config';
|
import { BASE_URL } from './buster_rest/config';
|
||||||
import type { RustApiError } from './buster_rest/errors';
|
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 () => {
|
export const getSupabaseTokenFromCookies = async () => {
|
||||||
const cookiesManager = await cookies();
|
const supabase = await createSupabaseServerClient();
|
||||||
const tokenCookie =
|
const sessionData = await supabase.auth.getSession();
|
||||||
cookiesManager.get('sb-127-auth-token') || cookiesManager.get('next-sb-access-token');
|
return sessionData.data?.session?.access_token || '';
|
||||||
return tokenCookie?.value || '';
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -20,54 +20,114 @@ const useSupabaseContextInternal = ({
|
||||||
supabaseContext: UseSupabaseUserContextType;
|
supabaseContext: UseSupabaseUserContextType;
|
||||||
}) => {
|
}) => {
|
||||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const { openErrorNotification, openInfoMessage } = useBusterNotifications();
|
const { openErrorNotification } = useBusterNotifications();
|
||||||
const [accessToken, setAccessToken] = useState(supabaseContext.accessToken || '');
|
const [accessToken, setAccessToken] = useState(supabaseContext.accessToken || '');
|
||||||
|
const refreshInFlightRef = useRef<Promise<string> | null>(null);
|
||||||
|
|
||||||
const isAnonymousUser: boolean =
|
const isAnonymousUser: boolean =
|
||||||
!supabaseContext.user?.id || supabaseContext.user?.is_anonymous === true;
|
!supabaseContext.user?.id || supabaseContext.user?.is_anonymous === true;
|
||||||
|
|
||||||
const getExpiresAt = useMemoizedFn((token?: string) => {
|
const getExpiresAt = useMemoizedFn((token?: string) => {
|
||||||
const decoded = jwtDecode(token || accessToken);
|
try {
|
||||||
const expiresAtDecoded = decoded?.exp || 0;
|
const decoded = jwtDecode(token || accessToken);
|
||||||
const ms = millisecondsFromUnixTimestamp(expiresAtDecoded);
|
const expiresAtDecoded = (decoded as { exp?: number } | undefined)?.exp ?? 0;
|
||||||
return ms;
|
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 () => {
|
const checkTokenValidity = useMemoizedFn(async () => {
|
||||||
try {
|
try {
|
||||||
const ms = getExpiresAt();
|
// Anonymous users do not require refresh; return current token as-is
|
||||||
const minutesUntilExpiration = ms / 60000;
|
|
||||||
const isTokenExpired = minutesUntilExpiration < PREEMTIVE_REFRESH_MINUTES; //5 minutes
|
|
||||||
|
|
||||||
if (isAnonymousUser) {
|
if (isAnonymousUser) {
|
||||||
return {
|
return {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
isTokenValid: isTokenExpired
|
isTokenValid: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTokenExpired) {
|
// If we don't have a token in memory, try to recover it from Supabase session
|
||||||
const { data: refreshedSession, error: refreshedSessionError } =
|
if (!accessToken) {
|
||||||
await supabase.auth.refreshSession();
|
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({
|
openErrorNotification({
|
||||||
title: 'Error refreshing session',
|
title: 'Error refreshing session',
|
||||||
description: 'Please refresh the page and try again',
|
description: 'Please refresh the page and try again',
|
||||||
duration: 120 * 1000 //2 minutes
|
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 {
|
return {
|
||||||
|
@ -85,15 +145,6 @@ const useSupabaseContextInternal = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const onUpdateToken = useMemoizedFn(
|
|
||||||
async ({ accessToken, expiresAt: _expiresAt }: { accessToken: string; expiresAt: number }) => {
|
|
||||||
setAccessToken(accessToken);
|
|
||||||
flushSync(() => {
|
|
||||||
//noop
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (supabaseContext.accessToken) {
|
if (supabaseContext.accessToken) {
|
||||||
setAccessToken(supabaseContext.accessToken);
|
setAccessToken(supabaseContext.accessToken);
|
||||||
|
@ -106,6 +157,10 @@ const useSupabaseContextInternal = ({
|
||||||
const refreshBuffer = PREEMTIVE_REFRESH_MINUTES * 60000; // Refresh minutes before expiration
|
const refreshBuffer = PREEMTIVE_REFRESH_MINUTES * 60000; // Refresh minutes before expiration
|
||||||
const timeUntilRefresh = Math.max(0, expiresInMs - refreshBuffer);
|
const timeUntilRefresh = Math.max(0, expiresInMs - refreshBuffer);
|
||||||
|
|
||||||
|
if (refreshTimerRef.current) {
|
||||||
|
clearTimeout(refreshTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
// Set up timer for future refresh
|
// Set up timer for future refresh
|
||||||
refreshTimerRef.current = setTimeout(() => {
|
refreshTimerRef.current = setTimeout(() => {
|
||||||
checkTokenValidity();
|
checkTokenValidity();
|
||||||
|
@ -120,7 +175,45 @@ const useSupabaseContextInternal = ({
|
||||||
clearTimeout(refreshTimerRef.current);
|
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 {
|
return {
|
||||||
isAnonymousUser,
|
isAnonymousUser,
|
||||||
|
|
|
@ -61,14 +61,14 @@ vi.mock('remark-gfm', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock Supabase client to prevent environment variable errors in tests
|
// Mock Supabase client to prevent environment variable errors in tests
|
||||||
vi.mock('@/lib/supabase/client', () => ({
|
vi.mock('@/lib/supabase/client', () => {
|
||||||
createBrowserClient: vi.fn(() => ({
|
const mockClient = {
|
||||||
auth: {
|
auth: {
|
||||||
refreshSession: vi.fn().mockResolvedValue({
|
refreshSession: vi.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
session: {
|
session: {
|
||||||
access_token: 'mock-token',
|
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
|
error: null
|
||||||
|
@ -76,7 +76,24 @@ vi.mock('@/lib/supabase/client', () => ({
|
||||||
getUser: vi.fn().mockResolvedValue({
|
getUser: vi.fn().mockResolvedValue({
|
||||||
data: { user: null },
|
data: { user: null },
|
||||||
error: 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