Add Stuffbox account connection, encrypted token lifecycle, direct media uploads, storage settings, and S3 fallback
Hop-State: A_06FP7VQ2F5KZQAY57YEBZV8 Hop-Proposal: R_06FP7VPDD7MJH2Z0013D7SR Hop-Task: T_06FP7QPNG91NT6WBGJD5MJG Hop-Attempt: AT_06FP7QPNGAEEAEHP3Q7CNEG
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { exchangeAuthorizationCode, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
|
||||
|
||||
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
|
||||
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
|
||||
const targetOrigin = safeJson(origin);
|
||||
const fallback = success ? '/settings/storage?stuffbox=connected' : '/settings/storage?stuffbox=error';
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
|
||||
<p>${success ? 'Stuffbox connected. You can close this window.' : 'Stuffbox could not be connected.'}</p>
|
||||
<script>
|
||||
if (window.opener) { window.opener.postMessage(${payload}, ${targetOrigin}); window.close(); }
|
||||
else { window.location.replace(${safeJson(fallback)}); }
|
||||
</script>
|
||||
</body></html>`;
|
||||
return new NextResponse(html, {
|
||||
status: success ? 200 : 400,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const pending = await consumeStuffboxConnectionState();
|
||||
const code = request.nextUrl.searchParams.get('code');
|
||||
const state = request.nextUrl.searchParams.get('state');
|
||||
const denied = request.nextUrl.searchParams.get('error');
|
||||
|
||||
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.');
|
||||
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
|
||||
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.');
|
||||
}
|
||||
|
||||
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
|
||||
code,
|
||||
codeVerifier: pending.verifier,
|
||||
redirectUri: pending.redirectUri,
|
||||
});
|
||||
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
|
||||
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.');
|
||||
} catch (error) {
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return popupResponse(origin, false, error.message);
|
||||
}
|
||||
console.error('Stuffbox callback error:', error);
|
||||
return popupResponse(origin, false, 'Stuffbox could not be connected.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
|
||||
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
|
||||
import { generatePkce } from '@/lib/stuffbox/crypto';
|
||||
import { STUFFBOX_SCOPES } from '@/lib/stuffbox/types';
|
||||
|
||||
function nodeOrigin(request: NextRequest): string {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
||||
if (appUrl) return new URL(appUrl).origin;
|
||||
const configured = process.env.NEXT_PUBLIC_NODE_DOMAIN?.trim();
|
||||
if (!configured) return request.nextUrl.origin;
|
||||
const protocol = /^(localhost|127\.0\.0\.1)(:|$)/.test(configured) ? 'http' : 'https';
|
||||
return new URL(configured.includes('://') ? configured : `${protocol}://${configured}`).origin;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const baseUrl = configuredStuffboxUrl();
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({
|
||||
error: 'Stuffbox is not configured on this node.',
|
||||
code: 'STUFFBOX_UNAVAILABLE',
|
||||
}, { status: 503 });
|
||||
}
|
||||
|
||||
const origin = nodeOrigin(request);
|
||||
const redirectUri = `${origin}/api/storage/stuffbox/callback`;
|
||||
const node = user.nodeId
|
||||
? await db.query.nodes.findFirst({ where: { id: user.nodeId } })
|
||||
: null;
|
||||
const pkce = generatePkce();
|
||||
const connection = await createConnectionRequest(baseUrl, {
|
||||
nodeName: node?.name || new URL(origin).host,
|
||||
callbackUrl: redirectUri,
|
||||
codeChallenge: pkce.challenge,
|
||||
state: pkce.state,
|
||||
scopes: STUFFBOX_SCOPES,
|
||||
});
|
||||
|
||||
await saveStuffboxConnectionState({
|
||||
userId: user.id,
|
||||
baseUrl,
|
||||
verifier: pkce.verifier,
|
||||
state: pkce.state,
|
||||
redirectUri,
|
||||
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
|
||||
});
|
||||
|
||||
return NextResponse.json({ authorizationUrl: connection.authorizationUrl });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
if (error instanceof StuffboxApiError) {
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
|
||||
}
|
||||
console.error('Stuffbox connection error:', error);
|
||||
return NextResponse.json({ error: 'Unable to start Stuffbox connection' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAuth } from '@/lib/auth';
|
||||
import { revokeToken } from '@/lib/stuffbox/client';
|
||||
import {
|
||||
getStuffboxConnection,
|
||||
readStuffboxRefreshToken,
|
||||
removeStuffboxConnection,
|
||||
} from '@/lib/stuffbox/tokens';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const user = await requireAuth();
|
||||
const connection = await getStuffboxConnection(user.id);
|
||||
if (connection) {
|
||||
try {
|
||||
await revokeToken(connection.baseUrl, readStuffboxRefreshToken(connection));
|
||||
} catch (error) {
|
||||
console.warn('Stuffbox token revocation failed; removing local connection:', error);
|
||||
}
|
||||
await removeStuffboxConnection(user.id);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Authentication required') {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
console.error('Stuffbox disconnect error:', error);
|
||||
return NextResponse.json({ error: 'Unable to disconnect Stuffbox' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user