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,46 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { completeUpload, StuffboxApiError } from '@/lib/stuffbox/client';
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
const bodySchema = z.object({ alt: z.string().max(1500).nullable().optional() });
type Context = { params: Promise<{ uploadId: string }> };
export async function POST(request: Request, context: Context) {
try {
const user = await requireAuth();
const body = bodySchema.parse(await request.json().catch(() => ({})));
const { uploadId } = await context.params;
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
const asset = await completeUpload(baseUrl, accessToken, uploadId);
const [record] = await db.insert(media).values({
userId: user.id,
postId: null,
url: asset.url,
storageProvider: 'stuffbox',
storageAssetId: asset.id,
altText: body.alt ?? null,
mimeType: asset.mimeType,
width: 0,
height: 0,
}).returning();
return NextResponse.json({ success: true, media: record, asset });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid upload completion' }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
return NextResponse.json({ error: 'Stuffbox is disconnected', code: 'STORAGE_NOT_CONFIGURED' }, { status: 409 });
}
if (error instanceof StuffboxApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
}
console.error('Stuffbox upload completion error:', error);
return NextResponse.json({ error: 'Unable to complete upload' }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAuth } from '@/lib/auth';
import { createUpload, StuffboxApiError } from '@/lib/stuffbox/client';
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
const uploadSchema = z.object({
filename: z.string().min(1).max(255),
mimeType: z.enum([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime',
]),
size: z.number().int().positive().max(100 * 1024 * 1024),
sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
}).superRefine((upload, context) => {
if (upload.mimeType.startsWith('image/') && upload.size > 10 * 1024 * 1024) {
context.addIssue({ code: 'too_big', maximum: 10 * 1024 * 1024, origin: 'number', inclusive: true, path: ['size'], message: 'Images must be 10MB or smaller' });
}
});
export async function POST(request: Request) {
try {
const user = await requireAuth();
const input = uploadSchema.parse(await request.json());
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
const upload = await createUpload(baseUrl, accessToken, input);
return NextResponse.json(upload, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid upload', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
return NextResponse.json({
error: 'Connect Stuffbox before uploading media.',
code: 'STORAGE_NOT_CONFIGURED',
}, { status: 409 });
}
if (error instanceof StuffboxApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
}
console.error('Stuffbox upload-session error:', error);
return NextResponse.json({ error: 'Unable to create upload session' }, { status: 500 });
}
}
@@ -6,6 +6,8 @@ import { requireAuth, verifyPassword } from '@/lib/auth';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { testS3Credentials, type StorageProvider } from '@/lib/storage/s3';
import { createStorageSession } from '@/lib/storage/session';
import { configuredStuffboxUrl } from '@/lib/stuffbox/client';
import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
const configurationSchema = z.object({
password: z.string().min(1),
@@ -18,6 +20,25 @@ const configurationSchema = z.object({
secretKey: z.string().min(10),
});
export async function GET() {
try {
const user = await requireAuth();
const stuffbox = await getStuffboxConnection(user.id);
return NextResponse.json({
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
s3Provider: user.storageProvider ?? null,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Storage status error:', error);
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await requireAuth();
@@ -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 });
}
}