Make Stuffbox linking resilient to cross-origin popup isolation and stale callbacks
Hop-State: A_06FP8WHY25K99094H3X60A8 Hop-Proposal: R_06FP8WGCVDDG61H0VZ1XQJR Hop-Task: T_06FP8V5D2HSJG08NRN6VS3G Hop-Attempt: AT_06FP8V5D2KATGJNV2M2SGRR
This commit is contained in:
@@ -28,6 +28,7 @@ export async function GET() {
|
|||||||
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
|
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
|
||||||
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
|
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
|
||||||
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
|
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
|
||||||
|
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
|
||||||
s3Provider: user.storageProvider ?? null,
|
s3Provider: user.storageProvider ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state'
|
|||||||
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
|
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
|
||||||
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
|
import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response';
|
||||||
|
|
||||||
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
|
function popupResponse(origin: string, success: boolean, message: string, attemptId?: string): NextResponse {
|
||||||
return new NextResponse(renderStuffboxPopupResponse(origin, success, message), {
|
return new NextResponse(renderStuffboxPopupResponse(origin, success, message, attemptId), {
|
||||||
status: success ? 200 : 400,
|
status: success ? 200 : 400,
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||||
});
|
});
|
||||||
@@ -14,16 +14,18 @@ function popupResponse(origin: string, success: boolean, message: string): NextR
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const origin = request.nextUrl.origin;
|
const origin = request.nextUrl.origin;
|
||||||
|
let attemptId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
const user = await requireAuth();
|
||||||
const pending = await consumeStuffboxConnectionState();
|
const pending = await consumeStuffboxConnectionState();
|
||||||
|
attemptId = pending?.state;
|
||||||
const code = request.nextUrl.searchParams.get('code');
|
const code = request.nextUrl.searchParams.get('code');
|
||||||
const state = request.nextUrl.searchParams.get('state');
|
const state = request.nextUrl.searchParams.get('state');
|
||||||
const denied = request.nextUrl.searchParams.get('error');
|
const denied = request.nextUrl.searchParams.get('error');
|
||||||
|
|
||||||
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.');
|
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.', attemptId);
|
||||||
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
|
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
|
||||||
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.');
|
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.', attemptId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
||||||
@@ -32,12 +34,12 @@ export async function GET(request: NextRequest) {
|
|||||||
redirectUri: pending.redirectUri,
|
redirectUri: pending.redirectUri,
|
||||||
});
|
});
|
||||||
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
||||||
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.');
|
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.', attemptId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof StuffboxApiError) {
|
if (error instanceof StuffboxApiError) {
|
||||||
return popupResponse(origin, false, error.message);
|
return popupResponse(origin, false, error.message, attemptId);
|
||||||
}
|
}
|
||||||
console.error('Stuffbox callback error:', error);
|
console.error('Stuffbox callback error:', error);
|
||||||
return popupResponse(origin, false, 'Stuffbox could not be connected.');
|
return popupResponse(origin, false, 'Stuffbox could not be connected.', attemptId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ export async function POST(request: NextRequest) {
|
|||||||
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ authorizationUrl: connection.authorizationUrl });
|
return NextResponse.json({
|
||||||
|
authorizationUrl: connection.authorizationUrl,
|
||||||
|
connectionStartedAt: new Date().toISOString(),
|
||||||
|
connectionAttempt: pkce.state,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'Authentication required') {
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Box, ChevronDown, ExternalLink } from 'lucide-react';
|
import { Box, ChevronDown, ExternalLink } from 'lucide-react';
|
||||||
import { getStorageProvider } from '@/lib/stuffbox/browser-upload';
|
import { hasNewStuffboxConnection } from '@/lib/stuffbox/browser-upload';
|
||||||
|
import {
|
||||||
|
monitorStuffboxConnection,
|
||||||
|
StuffboxConnectionCancelledError,
|
||||||
|
type StuffboxConnectionResult,
|
||||||
|
} from '@/lib/stuffbox/connection-monitor';
|
||||||
|
|
||||||
interface StorageConfigurationPromptProps {
|
interface StorageConfigurationPromptProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,6 +30,34 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
|
const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
|
||||||
const [showS3, setShowS3] = useState(false);
|
const [showS3, setShowS3] = useState(false);
|
||||||
|
const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false);
|
||||||
|
const connectionAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const connectionPopupRef = useRef<Window | null>(null);
|
||||||
|
|
||||||
|
const closeConnectionPopup = () => {
|
||||||
|
try { connectionPopupRef.current?.close(); } catch { /* COOP may sever the popup handle. */ }
|
||||||
|
connectionPopupRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelStuffboxConnection = () => {
|
||||||
|
connectionAbortRef.current?.abort();
|
||||||
|
connectionAbortRef.current = null;
|
||||||
|
closeConnectionPopup();
|
||||||
|
setIsConnectingStuffbox(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
connectionAbortRef.current?.abort();
|
||||||
|
try { connectionPopupRef.current?.close(); } catch { /* Best-effort cleanup. */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
connectionAbortRef.current?.abort();
|
||||||
|
connectionAbortRef.current = null;
|
||||||
|
try { connectionPopupRef.current?.close(); } catch { /* Best-effort cleanup. */ }
|
||||||
|
connectionPopupRef.current = null;
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -47,92 +80,72 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
|
|
||||||
const connectStuffbox = async () => {
|
const connectStuffbox = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setIsSubmitting(true);
|
setIsConnectingStuffbox(true);
|
||||||
const popup = window.open('', 'synapsis-stuffbox', 'popup,width=620,height=760');
|
const popup = window.open('', 'synapsis-stuffbox', 'popup,width=620,height=760');
|
||||||
if (!popup) {
|
if (!popup) {
|
||||||
setError('Your browser blocked the Stuffbox window. Allow popups and try again.');
|
setError('Your browser blocked the Stuffbox window. Allow popups and try again.');
|
||||||
setIsSubmitting(false);
|
setIsConnectingStuffbox(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
connectionPopupRef.current = popup;
|
||||||
popup.document.body.textContent = 'Connecting to Stuffbox…';
|
popup.document.body.textContent = 'Connecting to Stuffbox…';
|
||||||
|
const controller = new AbortController();
|
||||||
|
connectionAbortRef.current = controller;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/storage/stuffbox/connect', { method: 'POST' });
|
const response = await fetch('/api/storage/stuffbox/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
if (!response.ok || !data.authorizationUrl) throw new Error(data.error || 'Unable to connect Stuffbox');
|
if (!response.ok || !data.authorizationUrl || !data.connectionStartedAt || !data.connectionAttempt) {
|
||||||
const result = new Promise<void>((resolve, reject) => {
|
throw new Error(data.error || 'Unable to connect Stuffbox');
|
||||||
let settled = false;
|
}
|
||||||
let verifyingClosedPopup = false;
|
const result = monitorStuffboxConnection({
|
||||||
let channel: BroadcastChannel | null = null;
|
signal: controller.signal,
|
||||||
const timeout = window.setTimeout(() => {
|
checkConnected: async () => hasNewStuffboxConnection(data.connectionStartedAt),
|
||||||
finish({ type: 'synapsis:stuffbox', success: false, message: 'Stuffbox connection timed out.' });
|
subscribe: (finish) => {
|
||||||
}, 10 * 60_000);
|
let channel: BroadcastChannel | null = null;
|
||||||
const popupClosed = window.setInterval(() => {
|
const finishCurrentAttempt = (result: StuffboxConnectionResult) => {
|
||||||
if (popup.closed) void verifyConnectionAfterPopupClosed();
|
if (result.attemptId === data.connectionAttempt) finish(result);
|
||||||
}, 500);
|
};
|
||||||
|
|
||||||
async function verifyConnectionAfterPopupClosed() {
|
function receive(event: MessageEvent) {
|
||||||
if (settled || verifyingClosedPopup) return;
|
if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
|
||||||
verifyingClosedPopup = true;
|
finishCurrentAttempt(event.data as StuffboxConnectionResult);
|
||||||
window.clearInterval(popupClosed);
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 4 && !settled; attempt += 1) {
|
|
||||||
await new Promise(resolveDelay => window.setTimeout(resolveDelay, 250));
|
|
||||||
if (settled) return;
|
|
||||||
try {
|
|
||||||
if (await getStorageProvider() === 'stuffbox') {
|
|
||||||
finish({ type: 'synapsis:stuffbox', success: true, message: 'Stuffbox connected.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch { /* Retry while the callback finishes saving the connection. */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finish({ type: 'synapsis:stuffbox', success: false, message: 'The Stuffbox window was closed before connecting.' });
|
function receiveStorage(event: StorageEvent) {
|
||||||
}
|
if (event.key !== 'synapsis:stuffbox:result' || !event.newValue) return;
|
||||||
|
try { finishCurrentAttempt(JSON.parse(event.newValue)); } catch { /* Ignore unrelated storage values. */ }
|
||||||
|
}
|
||||||
|
|
||||||
function cleanup() {
|
window.addEventListener('message', receive);
|
||||||
window.clearTimeout(timeout);
|
window.addEventListener('storage', receiveStorage);
|
||||||
window.clearInterval(popupClosed);
|
if ('BroadcastChannel' in window) {
|
||||||
window.removeEventListener('message', receive);
|
channel = new BroadcastChannel('synapsis:stuffbox');
|
||||||
window.removeEventListener('storage', receiveStorage);
|
channel.onmessage = (event) => finishCurrentAttempt(event.data);
|
||||||
channel?.close();
|
}
|
||||||
}
|
return () => {
|
||||||
|
window.removeEventListener('message', receive);
|
||||||
function finish(data: { type?: string; success?: boolean; message?: string }) {
|
window.removeEventListener('storage', receiveStorage);
|
||||||
if (settled || data.type !== 'synapsis:stuffbox') return;
|
channel?.close();
|
||||||
settled = true;
|
};
|
||||||
cleanup();
|
},
|
||||||
window.focus();
|
|
||||||
if (data.success) resolve();
|
|
||||||
else reject(new Error(data.message || 'Stuffbox could not be connected.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function receive(event: MessageEvent) {
|
|
||||||
if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
|
|
||||||
finish(event.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function receiveStorage(event: StorageEvent) {
|
|
||||||
if (event.key !== 'synapsis:stuffbox:result' || !event.newValue) return;
|
|
||||||
try { finish(JSON.parse(event.newValue)); } catch { /* Ignore unrelated storage values. */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('message', receive);
|
|
||||||
window.addEventListener('storage', receiveStorage);
|
|
||||||
if ('BroadcastChannel' in window) {
|
|
||||||
channel = new BroadcastChannel('synapsis:stuffbox');
|
|
||||||
channel.onmessage = (event) => finish(event.data);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
popup.location.href = data.authorizationUrl;
|
popup.location.href = data.authorizationUrl;
|
||||||
await result;
|
await result;
|
||||||
if (!popup.closed) popup.close();
|
window.focus();
|
||||||
|
closeConnectionPopup();
|
||||||
await onConfigured();
|
await onConfigured();
|
||||||
} catch (connectError) {
|
} catch (connectError) {
|
||||||
if (!popup.closed) popup.close();
|
closeConnectionPopup();
|
||||||
setError(connectError instanceof Error ? connectError.message : 'Unable to connect Stuffbox');
|
if (!controller.signal.aborted && !(connectError instanceof StuffboxConnectionCancelledError)) {
|
||||||
|
setError(connectError instanceof Error ? connectError.message : 'Unable to connect Stuffbox');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
connectionAbortRef.current = null;
|
||||||
|
setIsConnectingStuffbox(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,12 +190,17 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
|
<button type="button" className="btn btn-primary" onClick={isConnectingStuffbox ? cancelStuffboxConnection : connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
|
||||||
{isSubmitting && !showS3 ? 'Connecting…' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
|
{isConnectingStuffbox ? 'Cancel connection' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
|
||||||
</button>
|
</button>
|
||||||
|
{isConnectingStuffbox && (
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', lineHeight: 1.45, marginTop: '10px', textAlign: 'center' }}>
|
||||||
|
Approve access in the Stuffbox window. This page will continue automatically.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
|
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} disabled={isConnectingStuffbox} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
|
||||||
Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
|
Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -214,7 +232,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
|
|
||||||
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
|
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
|
||||||
{variant === 'modal' && (
|
{variant === 'modal' && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={() => { cancelStuffboxConnection(); onCancel(); }} disabled={isSubmitting}>Cancel</button></div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -222,7 +240,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
|
|||||||
if (variant === 'inline') return content;
|
if (variant === 'inline') return content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={onCancel}>
|
<div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={() => { cancelStuffboxConnection(); onCancel(); }}>
|
||||||
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
|
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { hasNewStuffboxConnection } from './browser-upload';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe('hasNewStuffboxConnection', () => {
|
||||||
|
it('accepts a connection saved after the current attempt started', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
provider: 'stuffbox',
|
||||||
|
stuffboxUpdatedAt: '2026-07-15T06:00:01.000Z',
|
||||||
|
}), { status: 200 })));
|
||||||
|
|
||||||
|
await expect(hasNewStuffboxConnection('2026-07-15T06:00:00.000Z')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mistake an older connection for completion of a new attempt', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
provider: 'stuffbox',
|
||||||
|
stuffboxUpdatedAt: '2026-07-15T05:59:59.000Z',
|
||||||
|
}), { status: 200 })));
|
||||||
|
|
||||||
|
await expect(hasNewStuffboxConnection('2026-07-15T06:00:00.000Z')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ interface JsonResponse extends Record<string, unknown> {
|
|||||||
error?: string;
|
error?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
provider?: string | null;
|
provider?: string | null;
|
||||||
|
stuffboxUpdatedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageProvider = 'stuffbox' | 's3' | null;
|
export type StorageProvider = 'stuffbox' | 's3' | null;
|
||||||
@@ -45,6 +46,21 @@ export async function getStorageProvider(): Promise<StorageProvider> {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasNewStuffboxConnection(startedAt: string): Promise<boolean> {
|
||||||
|
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
|
||||||
|
const configuration = await json(response);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new MediaUploadError(configuration.error || 'Unable to verify the Stuffbox connection', undefined, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = typeof configuration.stuffboxUpdatedAt === 'string'
|
||||||
|
? Date.parse(configuration.stuffboxUpdatedAt)
|
||||||
|
: Number.NaN;
|
||||||
|
return configuration.provider === 'stuffbox'
|
||||||
|
&& Number.isFinite(updatedAt)
|
||||||
|
&& updatedAt >= Date.parse(startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
function directPut(
|
function directPut(
|
||||||
uploadUrl: string,
|
uploadUrl: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
monitorStuffboxConnection,
|
||||||
|
StuffboxConnectionCancelledError,
|
||||||
|
type StuffboxConnectionResult,
|
||||||
|
} from './connection-monitor';
|
||||||
|
|
||||||
|
function subscription() {
|
||||||
|
let receive: ((result: StuffboxConnectionResult) => void) | undefined;
|
||||||
|
return {
|
||||||
|
subscribe: (next: (result: StuffboxConnectionResult) => void) => {
|
||||||
|
receive = next;
|
||||||
|
return () => { receive = undefined; };
|
||||||
|
},
|
||||||
|
send: (result: StuffboxConnectionResult) => receive?.(result),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('monitorStuffboxConnection', () => {
|
||||||
|
it('uses persisted server state as the source of truth', async () => {
|
||||||
|
const messages = subscription();
|
||||||
|
let checks = 0;
|
||||||
|
const result = monitorStuffboxConnection({
|
||||||
|
subscribe: messages.subscribe,
|
||||||
|
checkConnected: async () => ++checks >= 3,
|
||||||
|
pollIntervalMs: 1,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toBeUndefined();
|
||||||
|
expect(checks).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies success messages against server state before resolving', async () => {
|
||||||
|
const messages = subscription();
|
||||||
|
let connected = false;
|
||||||
|
const result = monitorStuffboxConnection({
|
||||||
|
subscribe: messages.subscribe,
|
||||||
|
checkConnected: async () => connected,
|
||||||
|
pollIntervalMs: 20,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.send({ type: 'synapsis:stuffbox', success: true });
|
||||||
|
connected = true;
|
||||||
|
messages.send({ type: 'synapsis:stuffbox', success: true });
|
||||||
|
|
||||||
|
await expect(result).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces an explicit denial from Stuffbox', async () => {
|
||||||
|
const messages = subscription();
|
||||||
|
const result = monitorStuffboxConnection({
|
||||||
|
subscribe: messages.subscribe,
|
||||||
|
checkConnected: async () => false,
|
||||||
|
pollIntervalMs: 20,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.send({ type: 'synapsis:stuffbox', success: false, message: 'Access denied.' });
|
||||||
|
await expect(result).rejects.toThrow('Access denied.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be cancelled without waiting for the timeout', async () => {
|
||||||
|
const messages = subscription();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const result = monitorStuffboxConnection({
|
||||||
|
subscribe: messages.subscribe,
|
||||||
|
checkConnected: async () => false,
|
||||||
|
signal: controller.signal,
|
||||||
|
pollIntervalMs: 20,
|
||||||
|
timeoutMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await expect(result).rejects.toBeInstanceOf(StuffboxConnectionCancelledError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export interface StuffboxConnectionResult {
|
||||||
|
type?: string;
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
attemptId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitorOptions {
|
||||||
|
checkConnected: () => Promise<boolean>;
|
||||||
|
subscribe: (receive: (result: StuffboxConnectionResult) => void) => () => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StuffboxConnectionCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Stuffbox connection cancelled');
|
||||||
|
this.name = 'StuffboxConnectionCancelledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the server to confirm a Stuffbox connection.
|
||||||
|
*
|
||||||
|
* Popup window state is intentionally absent here. Cross-Origin-Opener-Policy
|
||||||
|
* can make a live cross-origin popup appear closed to its opener. Messages are
|
||||||
|
* useful accelerators, but the node's persisted connection is the authority.
|
||||||
|
*/
|
||||||
|
export function monitorStuffboxConnection({
|
||||||
|
checkConnected,
|
||||||
|
subscribe,
|
||||||
|
signal,
|
||||||
|
pollIntervalMs = 750,
|
||||||
|
timeoutMs = 10 * 60_000,
|
||||||
|
}: MonitorOptions): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let checking = false;
|
||||||
|
let cleanupSubscription = () => {};
|
||||||
|
const timers: {
|
||||||
|
interval?: ReturnType<typeof setInterval>;
|
||||||
|
timeout?: ReturnType<typeof setTimeout>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const abort = () => finish(new StuffboxConnectionCancelledError());
|
||||||
|
signal?.addEventListener('abort', abort, { once: true });
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (timers.interval !== undefined) globalThis.clearInterval(timers.interval);
|
||||||
|
if (timers.timeout !== undefined) globalThis.clearTimeout(timers.timeout);
|
||||||
|
signal?.removeEventListener('abort', abort);
|
||||||
|
cleanupSubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(error?: Error) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNow() {
|
||||||
|
if (settled || checking) return;
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
if (await checkConnected()) finish();
|
||||||
|
} catch {
|
||||||
|
// Transient status failures are retried until timeout or cancellation.
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupSubscription = subscribe((result) => {
|
||||||
|
if (result.type !== 'synapsis:stuffbox') return;
|
||||||
|
if (result.success) {
|
||||||
|
void checkNow();
|
||||||
|
} else {
|
||||||
|
finish(new Error(result.message || 'Stuffbox access was not approved.'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (settled) {
|
||||||
|
cleanupSubscription();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timers.interval = globalThis.setInterval(() => void checkNow(), pollIntervalMs);
|
||||||
|
timers.timeout = globalThis.setTimeout(() => {
|
||||||
|
finish(new Error('Stuffbox connection timed out. You can safely try again.'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
void checkNow();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,10 +3,11 @@ import { renderStuffboxPopupResponse } from './popup-response';
|
|||||||
|
|
||||||
describe('renderStuffboxPopupResponse', () => {
|
describe('renderStuffboxPopupResponse', () => {
|
||||||
it('notifies the main tab without navigating the popup into Synapsis', () => {
|
it('notifies the main tab without navigating the popup into Synapsis', () => {
|
||||||
const html = renderStuffboxPopupResponse('https://synapsis.example', true, 'Stuffbox connected.');
|
const html = renderStuffboxPopupResponse('https://synapsis.example', true, 'Stuffbox connected.', 'attempt-123');
|
||||||
|
|
||||||
expect(html).toContain("new BroadcastChannel('synapsis:stuffbox')");
|
expect(html).toContain("new BroadcastChannel('synapsis:stuffbox')");
|
||||||
expect(html).toContain("localStorage.setItem('synapsis:stuffbox:result'");
|
expect(html).toContain("localStorage.setItem('synapsis:stuffbox:result'");
|
||||||
|
expect(html).toContain('attempt-123');
|
||||||
expect(html).toContain('window.setTimeout(() => window.close(), 250)');
|
expect(html).toContain('window.setTimeout(() => window.close(), 250)');
|
||||||
expect(html).not.toContain('window.location.replace');
|
expect(html).not.toContain('window.location.replace');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string): string {
|
export function renderStuffboxPopupResponse(origin: string, success: boolean, message: string, attemptId?: string): string {
|
||||||
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
|
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
|
||||||
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
|
const payload = safeJson({ type: 'synapsis:stuffbox', success, message, attemptId });
|
||||||
const targetOrigin = safeJson(origin);
|
const targetOrigin = safeJson(origin);
|
||||||
|
|
||||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
|
||||||
|
|||||||
Reference in New Issue
Block a user