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,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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user