Make Stuffbox the exclusive media provider and simplify encrypted messaging copy

Hop-State: A_06FPCP16ZEAQDRH1JF5JRZG
Hop-Proposal: R_06FPCP0618KWV4X8SK82048
Hop-Task: T_06FPCM7MFZHQDCR4JBRHMG0
Hop-Attempt: AT_06FPCM7MFW92T7J4T312R08
This commit is contained in:
2026-07-15 08:14:41 -07:00
committed by Hop
parent 566d79347f
commit 9dc416b5f5
24 changed files with 143 additions and 2732 deletions
-61
View File
@@ -3,11 +3,9 @@
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import AutoTextarea from '@/components/AutoTextarea';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { useToast } from '@/lib/contexts/ToastContext';
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
import { refreshStorageSession } from '@/lib/storage/client';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
@@ -35,12 +33,8 @@ export default function AdminPage() {
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false);
const [pendingBannerFile, setPendingBannerFile] = useState<File | null>(null);
const [bannerPassword, setBannerPassword] = useState('');
const [bannerPromptError, setBannerPromptError] = useState('');
const [isRefreshingBannerSession, setIsRefreshingBannerSession] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
@@ -127,21 +121,12 @@ export default function AdminPage() {
setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings);
setPendingBannerFile(null);
setShowBannerSessionPrompt(false);
setBannerPassword('');
setBannerPromptError('');
} catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingBannerFile(file);
setShowBannerStorageConfiguration(true);
return;
}
if (error instanceof MediaUploadError && error.status === 401 && allowPrompt) {
setPendingBannerFile(file);
setBannerPromptError('');
setShowBannerSessionPrompt(true);
return;
}
console.error('Banner upload failed', error);
setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
} finally {
@@ -174,39 +159,6 @@ export default function AdminPage() {
}
};
const handleBannerSessionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pendingBannerFile) {
setShowBannerSessionPrompt(false);
return;
}
if (!bannerPassword.trim()) {
setBannerPromptError('Please enter your password');
return;
}
setIsRefreshingBannerSession(true);
setBannerPromptError('');
try {
await refreshStorageSession(bannerPassword.trim());
await uploadBannerFile(pendingBannerFile, false);
} catch (error) {
setBannerPromptError(error instanceof Error ? error.message : 'Failed to confirm password');
} finally {
setIsRefreshingBannerSession(false);
}
};
const handleBannerSessionCancel = () => {
setShowBannerSessionPrompt(false);
setPendingBannerFile(null);
setBannerPassword('');
setBannerPromptError('');
};
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -621,19 +573,6 @@ export default function AdminPage() {
</div>
)}
<StorageSessionPrompt
open={showBannerSessionPrompt}
isSubmitting={isRefreshingBannerSession}
password={bannerPassword}
error={bannerPromptError}
description="Please confirm your password to continue uploading this banner to your storage."
onPasswordChange={(nextPassword) => {
setBannerPassword(nextPassword);
setBannerPromptError('');
}}
onSubmit={handleBannerSessionSubmit}
onCancel={handleBannerSessionCancel}
/>
<StorageConfigurationPrompt
open={showBannerStorageConfiguration}
onConfigured={async () => {
-2
View File
@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { authenticateUser, createSession } from '@/lib/auth';
import { createStorageSession } from '@/lib/storage/session';
import { verifyTurnstileToken } from '@/lib/turnstile';
import { z } from 'zod';
@@ -31,7 +30,6 @@ export async function POST(request: Request) {
// Create session
await createSession(user.id);
await createStorageSession(user, data.password);
return NextResponse.json({
success: true,
-5
View File
@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server';
import { destroySession, getSession } from '@/lib/auth';
import { clearStorageSession } from '@/lib/storage/session';
import { z } from 'zod';
const logoutSchema = z.object({
@@ -15,10 +14,6 @@ export async function POST(request: Request) {
const targetUserId = data.userId ?? currentSession?.user.id;
if (targetUserId) {
await clearStorageSession(targetUserId);
}
await destroySession(targetUserId);
return NextResponse.json({ success: true });
+1 -30
View File
@@ -17,8 +17,6 @@ import {
BotHandleTakenError,
BotValidationError,
} from '@/lib/bots/botManager';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { createStorageSession, getStorageSession } from '@/lib/storage/session';
// Schema for creating a bot
const createBotSchema = z.object({
@@ -45,7 +43,6 @@ const createBotSchema = z.object({
timezone: z.string().optional(),
}).optional(),
autonomousMode: z.boolean().optional(),
ownerPassword: z.string().optional(),
});
/**
@@ -62,37 +59,11 @@ export async function POST(request: Request) {
const body = await request.json();
const data = createBotSchema.parse(body);
const storageSession =
(await getStorageSession(user.id)) ||
(data.ownerPassword ? await createStorageSession(user, data.ownerPassword) : null);
// Generate bot avatar using owner's S3 storage if no avatar URL
let botAvatarUrl: string | null | undefined = data.avatarUrl;
if (!botAvatarUrl && storageSession) {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
botHandle,
storageSession.endpoint || undefined,
storageSession.publicBaseUrl || undefined,
storageSession.region || 'us-east-1',
storageSession.bucket,
storageSession.accessKeyId,
storageSession.secretAccessKey
);
} catch (err) {
console.error('[Bot API] Failed to generate bot avatar:', err);
// Continue without avatar - user can set it later
}
}
const bot = await createBot(user.id, {
name: data.name,
handle: data.handle,
bio: data.bio,
avatarUrl: botAvatarUrl ?? undefined,
avatarUrl: data.avatarUrl,
headerUrl: data.headerUrl,
personality: data.personality,
llmProvider: data.llmProvider,
-115
View File
@@ -1,115 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { getStorageSession, createStorageSession } from '@/lib/storage/session';
import { uploadWithStorageCredentials } from '@/lib/storage/s3';
import { v4 as uuidv4 } from 'uuid';
import { getMaxMediaSize, getMediaKind } from '@/lib/media/upload-policy';
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const formData = await req.formData();
const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null;
const password = formData.get('password') as string | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
const mediaKind = getMediaKind(file.type);
if (mediaKind === 'unsupported') {
return NextResponse.json({
error: 'Invalid file type. Upload an image, video, MP3, M4A, AAC, WAV, OGG, or FLAC file.'
}, { status: 400 });
}
// Validate file size based on type
const maxSize = getMaxMediaSize(file.type);
if (maxSize !== null && file.size > maxSize) {
return NextResponse.json({
error: `File too large. Maximum size: ${mediaKind === 'image' ? '10MB' : '100MB'}`
}, { status: 400 });
}
// Check if user has S3 storage configured
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
return NextResponse.json({
error: 'Connect your storage before uploading media.',
code: 'STORAGE_NOT_CONFIGURED',
}, { status: 409 });
}
const storageSession =
(await getStorageSession(user.id)) ||
(password ? await createStorageSession(user, password) : null);
if (!storageSession) {
return NextResponse.json({
error: 'Upload session expired. Please sign in again.'
}, { status: 401 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// Upload to user's own S3-compatible storage
const uploadResult = await uploadWithStorageCredentials(
buffer,
filename,
file.type,
storageSession.provider,
storageSession.endpoint,
storageSession.publicBaseUrl,
storageSession.region,
storageSession.bucket,
storageSession.accessKeyId,
storageSession.secretAccessKey
);
// Store media record with S3 URL
if (db) {
const [mediaRecord] = await db.insert(media).values({
userId: user.id,
postId: null,
url: uploadResult.url,
altText,
mimeType: file.type,
width: 0, // TODO: Get actual dimensions
height: 0,
}).returning();
return NextResponse.json({
success: true,
media: mediaRecord,
url: uploadResult.url,
key: uploadResult.key,
});
}
return NextResponse.json({
success: true,
url: uploadResult.url,
key: uploadResult.key,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'Invalid storage password') {
return NextResponse.json({
error: 'Incorrect password. Please try again.'
}, { status: 401 });
}
if (error instanceof Error && error.message.includes('Storage')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
configuredStuffboxUrl: vi.fn(),
getStuffboxConnection: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({ requireAuth: mocks.requireAuth }));
vi.mock('@/lib/stuffbox/client', () => ({ configuredStuffboxUrl: mocks.configuredStuffboxUrl }));
vi.mock('@/lib/stuffbox/tokens', () => ({ getStuffboxConnection: mocks.getStuffboxConnection }));
import { GET } from './route';
describe('GET /api/storage/configuration', () => {
beforeEach(() => {
mocks.requireAuth.mockReset();
mocks.configuredStuffboxUrl.mockReset();
mocks.getStuffboxConnection.mockReset();
mocks.configuredStuffboxUrl.mockReturnValue('https://stuffbox.example');
mocks.getStuffboxConnection.mockResolvedValue(null);
});
it('does not expose legacy S3 credentials as an active storage provider', async () => {
mocks.requireAuth.mockResolvedValue({
id: 'user-1',
storageProvider: 's3',
storageBucket: 'legacy-bucket',
});
const response = await GET();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
provider: null,
stuffboxAvailable: true,
stuffboxBaseUrl: null,
stuffboxUpdatedAt: null,
});
});
});
+2 -74
View File
@@ -1,35 +1,17 @@
import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db, users } from '@/db';
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 { requireAuth } from '@/lib/auth';
import { configuredStuffboxUrl } from '@/lib/stuffbox/client';
import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
const configurationSchema = z.object({
password: z.string().min(1),
provider: z.enum(['s3', 'r2', 'b2', 'wasabi', 'contabo']),
endpoint: z.string().trim().nullable().optional(),
publicBaseUrl: z.string().trim().nullable().optional(),
region: z.string().trim().min(2),
bucket: z.string().trim().min(3),
accessKey: z.string().min(10),
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,
provider: stuffbox ? 'stuffbox' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
s3Provider: user.storageProvider ?? null,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
@@ -39,57 +21,3 @@ export async function GET() {
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
}
}
export async function POST(request: Request) {
try {
const user = await requireAuth();
const data = configurationSchema.parse(await request.json());
if (!user.passwordHash || !(await verifyPassword(data.password, user.passwordHash))) {
return NextResponse.json({ error: 'Incorrect account password' }, { status: 401 });
}
const storageTest = await testS3Credentials(
data.endpoint || null,
data.region,
data.bucket,
data.accessKey,
data.secretKey
);
if (!storageTest.success) {
return NextResponse.json(
{ error: `Storage connection failed: ${storageTest.error}` },
{ status: 400 }
);
}
const storageProvider = data.provider as StorageProvider;
const storageAccessKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.accessKey, data.password));
const storageSecretKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.secretKey, data.password));
const storageValues = {
storageProvider,
storageEndpoint: data.endpoint || null,
storagePublicBaseUrl: data.publicBaseUrl || null,
storageRegion: data.region,
storageBucket: data.bucket,
storageAccessKeyEncrypted,
storageSecretKeyEncrypted,
};
await db.update(users).set(storageValues).where(eq(users.id, user.id));
await createStorageSession({ ...user, ...storageValues }, data.password);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid storage configuration', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Storage configuration error:', error);
return NextResponse.json({ error: 'Failed to configure storage' }, { status: 500 });
}
}
-44
View File
@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAuth, verifyPassword } from '@/lib/auth';
import { clearStorageSession, createStorageSession } from '@/lib/storage/session';
const bodySchema = z.object({
password: z.string().min(1),
});
export async function POST(request: Request) {
try {
const user = await requireAuth();
const body = await request.json();
const data = bodySchema.parse(body);
if (!user.passwordHash) {
return NextResponse.json({ error: 'Account has no password set' }, { status: 400 });
}
const isValid = await verifyPassword(data.password, user.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 });
}
await createStorageSession(user, data.password);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Storage session error:', error);
return NextResponse.json({ error: 'Failed to refresh upload session' }, { status: 500 });
}
}
export async function DELETE() {
await clearStorageSession();
return NextResponse.json({ success: true });
}
+1 -1
View File
@@ -988,7 +988,7 @@ export default function ChatPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: '11px', color: 'var(--foreground-secondary)' }}>
<LockKeyhole size={11} aria-hidden="true" />
<span>{selectedEncryptionReady
? 'End-to-end encryption ready'
? 'End-to-end encrypted messaging'
: selectedEncryptionError
? 'Encryption unavailable'
: 'Verifying encryption…'}</span>
+1 -1
View File
@@ -44,7 +44,7 @@ export default function SettingsPage() {
Media Storage
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Connect Stuffbox or an S3-compatible bucket
Connect and manage Stuffbox
</div>
</Link>
+6 -8
View File
@@ -2,15 +2,14 @@
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { Box, Cloud, ExternalLink, HardDrive } from 'lucide-react';
import { Box, ExternalLink } from 'lucide-react';
import { ArrowLeftIcon } from '@/components/Icons';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
interface StorageStatus {
provider: 'stuffbox' | 's3' | null;
provider: 'stuffbox' | null;
stuffboxAvailable: boolean;
stuffboxBaseUrl: string | null;
s3Provider: string | null;
}
export default function StorageSettingsPage() {
@@ -68,10 +67,9 @@ export default function StorageSettingsPage() {
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />}
<Box size={22} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : status.provider === 's3' ? 'S3 storage connected' : 'No media storage connected'}</div>
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : 'Stuffbox not connected'}</div>
</div>
</div>
{status?.provider === 'stuffbox' && (
@@ -89,10 +87,10 @@ export default function StorageSettingsPage() {
{status?.provider !== 'stuffbox' && (
<div className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
{status?.provider ? 'Change storage' : 'Connect media storage'}
Connect Stuffbox
</h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
Choose Stuffbox for the simplest setup, or connect an S3-compatible bucket you already own.
Connect the official Synapsis storage partner to upload portable media from your account.
</p>
<StorageConfigurationPrompt open onConfigured={loadStatus} onCancel={() => {}} variant="inline" />
</div>