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:
2026-07-14 21:00:27 -07:00
committed by Hop
parent 752298a405
commit 9f0bfb59ab
26 changed files with 7657 additions and 168 deletions
@@ -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 });
}
}