From 28eb46d6531ca5fbd6d5afc82037c730514bd664 Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 7 Mar 2026 18:14:02 -0800 Subject: [PATCH] Make user media uploads session-backed --- src/app/admin/page.tsx | 2 +- src/app/api/auth/login/route.ts | 2 + src/app/api/auth/logout/route.ts | 2 + src/app/api/auth/register/route.ts | 2 + src/app/api/bots/route.ts | 33 ++- src/app/api/media/upload/route.ts | 31 +-- src/app/api/storage/session/route.ts | 44 ++++ src/app/bots/[id]/edit/page.tsx | 5 - src/app/bots/new/page.tsx | 6 - src/app/u/[handle]/page.tsx | 6 - src/components/UserStorageImageUpload.tsx | 256 ++++++---------------- src/lib/storage/s3.ts | 100 +++++---- src/lib/storage/session.ts | 136 ++++++++++++ 13 files changed, 340 insertions(+), 285 deletions(-) create mode 100644 src/app/api/storage/session/route.ts create mode 100644 src/lib/storage/session.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8710b9c..a1f9600 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -121,7 +121,7 @@ export default function AdminPage() { await handleSaveSettings(nextSettings); } catch (error) { console.error('Banner upload failed', error); - setBannerUploadError('Upload failed. Please try again.'); + setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); } finally { setIsUploadingBanner(false); } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5626ece..fc3630b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,5 +1,6 @@ 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'; @@ -30,6 +31,7 @@ export async function POST(request: Request) { // Create session await createSession(user.id); + await createStorageSession(user, data.password); return NextResponse.json({ success: true, diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index bb56a92..777d598 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from 'next/server'; import { destroySession } from '@/lib/auth'; +import { clearStorageSession } from '@/lib/storage/session'; export async function POST() { try { + await clearStorageSession(); await destroySession(); return NextResponse.json({ success: true }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 9d237cb..16e3ae1 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { registerUser, createSession } from '@/lib/auth'; +import { createStorageSession } from '@/lib/storage/session'; import { db, nodes, users } from '@/db'; import { eq } from 'drizzle-orm'; import { verifyTurnstileToken } from '@/lib/turnstile'; @@ -97,6 +98,7 @@ export async function POST(request: Request) { // Create session for new user await createSession(user.id); + await createStorageSession(user, data.password); return NextResponse.json({ success: true, diff --git a/src/app/api/bots/route.ts b/src/app/api/bots/route.ts index f59c3e7..1b4844a 100644 --- a/src/app/api/bots/route.ts +++ b/src/app/api/bots/route.ts @@ -17,7 +17,8 @@ import { BotHandleTakenError, BotValidationError, } from '@/lib/bots/botManager'; -import { generateAndUploadAvatarToUserStorage, decryptS3Credentials } from '@/lib/storage/s3'; +import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3'; +import { createStorageSession, getStorageSession } from '@/lib/storage/session'; // Schema for creating a bot const createBotSchema = z.object({ @@ -43,7 +44,6 @@ const createBotSchema = z.object({ timezone: z.string().optional(), }).optional(), autonomousMode: z.boolean().optional(), - // Optional: password to generate avatar using owner's S3 storage ownerPassword: z.string().optional(), }); @@ -61,28 +61,25 @@ export async function POST(request: Request) { const body = await request.json(); const data = createBotSchema.parse(body); - // Generate bot avatar using owner's S3 storage if password provided and no avatar URL + 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 && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) { + if (!botAvatarUrl && storageSession) { try { const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`; - - // Decrypt the storage credentials - const { accessKeyId, secretAccessKey } = decryptS3Credentials( - user.storageAccessKeyEncrypted, - user.storageSecretKeyEncrypted, - data.ownerPassword - ); - + botAvatarUrl = await generateAndUploadAvatarToUserStorage( botHandle, - user.storageEndpoint || undefined, - user.storagePublicBaseUrl || undefined, - user.storageRegion || 'auto', - user.storageBucket, - accessKeyId, - secretAccessKey + 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); diff --git a/src/app/api/media/upload/route.ts b/src/app/api/media/upload/route.ts index 0ee2ec5..242b47f 100644 --- a/src/app/api/media/upload/route.ts +++ b/src/app/api/media/upload/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, media } from '@/db'; import { requireAuth } from '@/lib/auth'; -import { uploadToUserStorage } from '@/lib/storage/s3'; +import { getStorageSession, createStorageSession } from '@/lib/storage/session'; +import { uploadWithStorageCredentials } from '@/lib/storage/s3'; import { v4 as uuidv4 } from 'uuid'; const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images @@ -47,10 +48,13 @@ export async function POST(req: NextRequest) { }, { status: 400 }); } - // Require password to decrypt storage credentials - if (!password) { - return NextResponse.json({ - error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.' + 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 }); } @@ -58,18 +62,17 @@ export async function POST(req: NextRequest) { const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`; // Upload to user's own S3-compatible storage - const uploadResult = await uploadToUserStorage( + const uploadResult = await uploadWithStorageCredentials( buffer, filename, file.type, - user.storageProvider as any, - user.storageEndpoint, - user.storagePublicBaseUrl, - user.storageRegion || 'us-east-1', - user.storageBucket || '', - user.storageAccessKeyEncrypted, - user.storageSecretKeyEncrypted, - password + storageSession.provider, + storageSession.endpoint, + storageSession.publicBaseUrl, + storageSession.region, + storageSession.bucket, + storageSession.accessKeyId, + storageSession.secretAccessKey ); // Store media record with S3 URL diff --git a/src/app/api/storage/session/route.ts b/src/app/api/storage/session/route.ts new file mode 100644 index 0000000..69ea6cf --- /dev/null +++ b/src/app/api/storage/session/route.ts @@ -0,0 +1,44 @@ +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 }); +} diff --git a/src/app/bots/[id]/edit/page.tsx b/src/app/bots/[id]/edit/page.tsx index 3f4e44b..7cc7171 100644 --- a/src/app/bots/[id]/edit/page.tsx +++ b/src/app/bots/[id]/edit/page.tsx @@ -47,7 +47,6 @@ export default function EditBotPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); - const [storagePassword, setStoragePassword] = useState(''); const [formData, setFormData] = useState({ name: '', @@ -371,8 +370,6 @@ export default function EditBotPage() { setError(''); setFormData(prev => ({ ...prev, avatarUrl })); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={48} previewHeight={48} previewBorderRadius="50%" @@ -387,8 +384,6 @@ export default function EditBotPage() { setError(''); setFormData(prev => ({ ...prev, headerUrl })); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={120} previewHeight={40} previewBorderRadius="4px" diff --git a/src/app/bots/new/page.tsx b/src/app/bots/new/page.tsx index 498a44e..7004112 100644 --- a/src/app/bots/new/page.tsx +++ b/src/app/bots/new/page.tsx @@ -40,7 +40,6 @@ export default function NewBotPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); - const [storagePassword, setStoragePassword] = useState(''); const [formData, setFormData] = useState({ name: '', @@ -183,7 +182,6 @@ export default function NewBotPage() { llmProvider: formData.llmProvider, llmModel: formData.llmModel, llmApiKey: formData.llmApiKey, - ownerPassword: storagePassword || undefined, autonomousMode: formData.autonomousMode, schedule: formData.autonomousMode ? { type: 'interval', @@ -321,8 +319,6 @@ export default function NewBotPage() { setError(''); setFormData(prev => ({ ...prev, avatarUrl })); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={48} previewHeight={48} previewBorderRadius="50%" @@ -337,8 +333,6 @@ export default function NewBotPage() { setError(''); setFormData(prev => ({ ...prev, headerUrl })); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={120} previewHeight={40} previewBorderRadius="4px" diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index ff1094d..c0b19a6 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -115,8 +115,6 @@ export default function ProfilePage() { const [isSaving, setIsSaving] = useState(false); const [isBlocked, setIsBlocked] = useState(false); const [showMenu, setShowMenu] = useState(false); - const [storagePassword, setStoragePassword] = useState(''); - useEffect(() => { setIsEditing(false); setSaveError(null); @@ -745,8 +743,6 @@ export default function ProfilePage() { setSaveError(null); setProfileForm({ ...profileForm, avatarUrl }); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={48} previewHeight={48} previewBorderRadius="50%" @@ -760,8 +756,6 @@ export default function ProfilePage() { setSaveError(null); setProfileForm({ ...profileForm, headerUrl }); }} - password={storagePassword} - onPasswordChange={setStoragePassword} previewWidth={120} previewHeight={40} previewBorderRadius="4px" diff --git a/src/components/UserStorageImageUpload.tsx b/src/components/UserStorageImageUpload.tsx index e2e3e6e..b70295a 100644 --- a/src/components/UserStorageImageUpload.tsx +++ b/src/components/UserStorageImageUpload.tsx @@ -1,13 +1,11 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; interface UserStorageImageUploadProps { label: string; value: string; onChange: (url: string) => void; - password: string; - onPasswordChange: (password: string) => void; helperText?: string; previewWidth?: number; previewHeight?: number; @@ -19,8 +17,6 @@ export function UserStorageImageUpload({ label, value, onChange, - password, - onPasswordChange, helperText, previewWidth = 48, previewHeight = 48, @@ -29,27 +25,6 @@ export function UserStorageImageUpload({ }: UserStorageImageUploadProps) { const inputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); - const [showPasswordPrompt, setShowPasswordPrompt] = useState(false); - const [pendingFile, setPendingFile] = useState(null); - const [passwordInput, setPasswordInput] = useState(password); - const [promptError, setPromptError] = useState(''); - - useEffect(() => { - if (!showPasswordPrompt) { - setPromptError(''); - } - }, [showPasswordPrompt]); - - useEffect(() => { - if (showPasswordPrompt && !passwordInput && password) { - setPasswordInput(password); - } - }, [showPasswordPrompt, password, passwordInput]); - - const reportError = (message: string) => { - setPromptError(message); - onError?.(message); - }; const resetFileInput = () => { if (inputRef.current) { @@ -57,14 +32,12 @@ export function UserStorageImageUpload({ } }; - const uploadFile = async (file: File, uploadPassword: string) => { + const uploadFile = async (file: File) => { setIsUploading(true); - setPromptError(''); try { const formData = new FormData(); formData.append('file', file); - formData.append('password', uploadPassword); const res = await fetch('/api/media/upload', { method: 'POST', @@ -76,25 +49,16 @@ export function UserStorageImageUpload({ const message = data.error || 'Upload failed'; if (res.status === 401) { - onPasswordChange(''); - setPasswordInput(''); - setPendingFile(file); - setShowPasswordPrompt(true); - setPromptError(message); - return; + throw new Error(message || 'Upload session expired. Please sign in again.'); } throw new Error(message); } - onPasswordChange(uploadPassword); onChange(data.media?.url || data.url); onError?.(''); - setPendingFile(null); - setShowPasswordPrompt(false); - setPasswordInput(uploadPassword); } catch (error) { - reportError(error instanceof Error ? error.message : 'Upload failed'); + onError?.(error instanceof Error ? error.message : 'Upload failed'); } finally { setIsUploading(false); resetFileInput(); @@ -104,169 +68,73 @@ export function UserStorageImageUpload({ const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; - - setPendingFile(file); - - if (password.trim()) { - await uploadFile(file, password.trim()); - return; - } - - setPasswordInput(''); - setShowPasswordPrompt(true); - }; - - const handlePasswordSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - - if (!pendingFile) { - setShowPasswordPrompt(false); - return; - } - - if (!passwordInput.trim()) { - setPromptError('Please enter your password'); - return; - } - - await uploadFile(pendingFile, passwordInput.trim()); - }; - - const handleCancel = () => { - setShowPasswordPrompt(false); - setPendingFile(null); - setPasswordInput(password); - setPromptError(''); - resetFileInput(); + await uploadFile(file); }; return ( - <> -
-
); } diff --git a/src/lib/storage/s3.ts b/src/lib/storage/s3.ts index 13328c7..3a4a8eb 100644 --- a/src/lib/storage/s3.ts +++ b/src/lib/storage/s3.ts @@ -22,6 +22,24 @@ interface StorageUploadResult { key: string; } +function buildStorageUrl( + key: string, + endpoint: string | null | undefined, + publicBaseUrl: string | null | undefined, + region: string, + bucket: string +): string { + if (publicBaseUrl) { + return `${publicBaseUrl.replace(/\/$/, '')}/${key}`; + } + + if (endpoint) { + return `${endpoint}/${bucket}/${key}`; + } + + return `https://${bucket}.s3.${region}.amazonaws.com/${key}`; +} + /** * Decrypt S3 credentials from encrypted storage */ @@ -76,23 +94,48 @@ export async function uploadToUserStorage( encryptedSecretKey: string, password: string ): Promise { - // Decrypt credentials const { accessKeyId, secretAccessKey } = decryptS3Credentials( encryptedAccessKey, encryptedSecretKey, password ); - // Create S3 client + return uploadWithStorageCredentials( + file, + filename, + mimeType, + provider, + endpoint, + publicBaseUrl, + region, + bucket, + accessKeyId, + secretAccessKey + ); +} + +export async function uploadWithStorageCredentials( + file: Buffer, + filename: string, + mimeType: string, + _provider: StorageProvider, + endpoint: string | null, + publicBaseUrl: string | null, + region: string, + bucket: string, + accessKeyId: string, + secretAccessKey: string +): Promise { + const normalizedRegion = region || 'us-east-1'; + const s3 = createS3Client({ endpoint: endpoint || undefined, - region, + region: normalizedRegion, accessKeyId, secretAccessKey, bucket, }); - // Upload file const key = `synapsis/${filename}`; await s3.send(new PutObjectCommand({ @@ -102,19 +145,7 @@ export async function uploadToUserStorage( ContentType: mimeType, })); - // Construct URL based on provider - let url: string; - - // Priority: user's publicBaseUrl > construct from endpoint > AWS format - if (publicBaseUrl) { - url = `${publicBaseUrl.replace(/\/$/, '')}/${key}`; - } else if (endpoint) { - // Custom endpoint (R2, B2, Contabo) - url = `${endpoint}/${bucket}/${key}`; - } else { - // AWS S3 standard URL - url = `https://${bucket}.s3.${region}.amazonaws.com/${key}`; - } + const url = buildStorageUrl(key, endpoint, publicBaseUrl, normalizedRegion, bucket); return { url, key }; } @@ -193,33 +224,20 @@ export async function generateAndUploadAvatarToUserStorage( const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - // 2. Upload to user's S3 - const s3 = createS3Client({ - endpoint: endpoint || undefined, + const result = await uploadWithStorageCredentials( + buffer, + `avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`, + 'image/png', + 's3', + endpoint || null, + publicBaseUrl || null, region, - accessKeyId: accessKey, - secretAccessKey: secretKey, bucket, - }); + accessKey, + secretKey + ); - const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`; - - await s3.send(new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: buffer, - ContentType: 'image/png', - })); - - // 3. Return URL - // Priority: user's publicBaseUrl > construct from endpoint > AWS format - if (publicBaseUrl) { - return `${publicBaseUrl.replace(/\/$/, '')}/${key}`; - } - if (endpoint) { - return `${endpoint}/${bucket}/${key}`; - } - return `https://${bucket}.s3.${region}.amazonaws.com/${key}`; + return result.url; } catch (error) { console.error('Error generating/uploading avatar:', error); diff --git a/src/lib/storage/session.ts b/src/lib/storage/session.ts new file mode 100644 index 0000000..a869fdd --- /dev/null +++ b/src/lib/storage/session.ts @@ -0,0 +1,136 @@ +import crypto from 'crypto'; +import { cookies } from 'next/headers'; +import type { users } from '@/db'; +import { decryptS3Credentials, type StorageProvider } from '@/lib/storage/s3'; + +const STORAGE_SESSION_COOKIE = 'synapsis_storage_session'; +const STORAGE_SESSION_TTL_MS = 12 * 60 * 60 * 1000; + +interface StorageSessionPayload { + userId: string; + provider: StorageProvider; + endpoint: string | null; + publicBaseUrl: string | null; + region: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + expiresAt: number; +} + +function getEncryptionKey(): Buffer { + const secret = process.env.AUTH_SECRET; + + if (!secret || secret.length < 16) { + throw new Error('AUTH_SECRET is not configured for storage sessions'); + } + + return crypto.createHash('sha256').update(secret).digest(); +} + +function encryptPayload(payload: StorageSessionPayload): string { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([ + cipher.update(JSON.stringify(payload), 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([iv, tag, encrypted]).toString('base64url'); +} + +function decryptPayload(token: string): StorageSessionPayload { + const key = getEncryptionKey(); + const raw = Buffer.from(token, 'base64url'); + + if (raw.length < 29) { + throw new Error('Invalid storage session token'); + } + + const iv = raw.subarray(0, 12); + const tag = raw.subarray(12, 28); + const encrypted = raw.subarray(28); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf8'); + + return JSON.parse(decrypted) as StorageSessionPayload; +} + +export async function createStorageSession( + user: typeof users.$inferSelect, + password: string +): Promise { + if ( + !user.storageProvider || + !user.storageAccessKeyEncrypted || + !user.storageSecretKeyEncrypted || + !user.storageBucket + ) { + await clearStorageSession(); + return null; + } + + const { accessKeyId, secretAccessKey } = decryptS3Credentials( + user.storageAccessKeyEncrypted, + user.storageSecretKeyEncrypted, + password + ); + + const payload: StorageSessionPayload = { + userId: user.id, + provider: user.storageProvider as StorageProvider, + endpoint: user.storageEndpoint, + publicBaseUrl: user.storagePublicBaseUrl, + region: user.storageRegion || 'us-east-1', + bucket: user.storageBucket, + accessKeyId, + secretAccessKey, + expiresAt: Date.now() + STORAGE_SESSION_TTL_MS, + }; + + const cookieStore = await cookies(); + cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayload(payload), { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + expires: new Date(payload.expiresAt), + }); + + return payload; +} + +export async function getStorageSession(userId: string): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value; + + if (!token) { + return null; + } + + try { + const payload = decryptPayload(token); + + if (payload.userId !== userId || payload.expiresAt <= Date.now()) { + await clearStorageSession(); + return null; + } + + return payload; + } catch { + await clearStorageSession(); + return null; + } +} + +export async function clearStorageSession(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(STORAGE_SESSION_COOKIE); +}