Remove shared storage and restore user-owned uploads

This commit is contained in:
cyph3rasi
2026-03-07 17:03:50 -08:00
parent 180164f7d2
commit 70e6b6fdf6
15 changed files with 392 additions and 523 deletions
+5
View File
@@ -102,6 +102,11 @@ export async function POST(req: NextRequest) {
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 });
}
-53
View File
@@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// S3 Configuration
const s3 = new S3Client({
region: process.env.STORAGE_REGION || 'us-east-1',
endpoint: process.env.STORAGE_ENDPOINT,
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || '',
secretAccessKey: process.env.STORAGE_SECRET_KEY || '',
},
forcePathStyle: true, // Needed for many S3-compatible providers
});
const bucket = process.env.STORAGE_BUCKET || 'synapsis';
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: buffer,
ContentType: file.type,
ACL: 'public-read', // Depends on bucket policy, but often needed
}));
// Construct Public URL
let url = '';
if (process.env.STORAGE_PUBLIC_BASE_URL) {
url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`;
} else if (process.env.STORAGE_ENDPOINT) {
url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`;
} else {
return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 });
}
return NextResponse.json({ url });
} catch (error) {
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
+32 -114
View File
@@ -12,6 +12,7 @@ import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
interface ContentSource {
@@ -46,8 +47,7 @@ export default function EditBotPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [storagePassword, setStoragePassword] = useState('');
const [formData, setFormData] = useState({
name: '',
@@ -364,119 +364,37 @@ export default function EditBotPage() {
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Avatar
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
}
} catch (err) {
console.error('Avatar upload failed:', err);
setError('Avatar upload failed');
} finally {
setUploadingAvatar(false);
}
}}
disabled={uploadingAvatar}
style={{ display: 'none' }}
/>
</label>
{formData.avatarUrl && (
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.avatarUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Square image recommended (optional)
</p>
</div>
<UserStorageImageUpload
label="Avatar"
value={formData.avatarUrl}
onChange={(avatarUrl) => {
setError('');
setFormData(prev => ({ ...prev, avatarUrl }));
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48}
previewHeight={48}
previewBorderRadius="50%"
helperText="Square image recommended (optional)"
onError={setError}
/>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Banner
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingBanner ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingBanner(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, headerUrl: data.url }));
}
} catch (err) {
console.error('Banner upload failed:', err);
setError('Banner upload failed');
} finally {
setUploadingBanner(false);
}
}}
disabled={uploadingBanner}
style={{ display: 'none' }}
/>
</label>
{formData.headerUrl && (
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.headerUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Wide image recommended, e.g. 1500x500 (optional)
</p>
</div>
<UserStorageImageUpload
label="Banner"
value={formData.headerUrl}
onChange={(headerUrl) => {
setError('');
setFormData(prev => ({ ...prev, headerUrl }));
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120}
previewHeight={40}
previewBorderRadius="4px"
helperText="Wide image recommended, e.g. 1500x500 (optional)"
onError={setError}
/>
</div>
);
+33 -114
View File
@@ -12,6 +12,7 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons';
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
import { Bot, Sparkles, Rss, Clock, Trash2 } from 'lucide-react';
interface ContentSource {
@@ -39,8 +40,7 @@ export default function NewBotPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [storagePassword, setStoragePassword] = useState('');
const [formData, setFormData] = useState({
name: '',
@@ -183,6 +183,7 @@ export default function NewBotPage() {
llmProvider: formData.llmProvider,
llmModel: formData.llmModel,
llmApiKey: formData.llmApiKey,
ownerPassword: storagePassword || undefined,
autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? {
type: 'interval',
@@ -313,119 +314,37 @@ export default function NewBotPage() {
</p>
</div>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Avatar
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingAvatar ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, avatarUrl: data.url }));
}
} catch (err) {
console.error('Avatar upload failed:', err);
setError('Avatar upload failed');
} finally {
setUploadingAvatar(false);
}
}}
disabled={uploadingAvatar}
style={{ display: 'none' }}
/>
</label>
{formData.avatarUrl && (
<div style={{ width: '48px', height: '48px', borderRadius: '50%', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.avatarUrl} alt="Avatar preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.avatarUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, avatarUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Square image recommended (optional)
</p>
</div>
<UserStorageImageUpload
label="Avatar"
value={formData.avatarUrl}
onChange={(avatarUrl) => {
setError('');
setFormData(prev => ({ ...prev, avatarUrl }));
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48}
previewHeight={48}
previewBorderRadius="50%"
helperText="Square image recommended (optional)"
onError={setError}
/>
<div>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
Banner
</label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: 'pointer' }}>
{uploadingBanner ? 'Uploading...' : 'Choose File'}
<input
type="file"
accept="image/*"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingBanner(true);
try {
const uploadData = new FormData();
uploadData.append('file', file);
const res = await fetch('/api/uploads', {
method: 'POST',
body: uploadData,
});
const data = await res.json();
if (data.url) {
setFormData(prev => ({ ...prev, headerUrl: data.url }));
}
} catch (err) {
console.error('Banner upload failed:', err);
setError('Banner upload failed');
} finally {
setUploadingBanner(false);
}
}}
disabled={uploadingBanner}
style={{ display: 'none' }}
/>
</label>
{formData.headerUrl && (
<div style={{ width: '120px', height: '40px', borderRadius: '4px', overflow: 'hidden', border: '1px solid var(--border)' }}>
<img src={formData.headerUrl} alt="Banner preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
)}
{formData.headerUrl && (
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, headerUrl: '' }))}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
Wide image recommended, e.g. 1500x500 (optional)
</p>
</div>
<UserStorageImageUpload
label="Banner"
value={formData.headerUrl}
onChange={(headerUrl) => {
setError('');
setFormData(prev => ({ ...prev, headerUrl }));
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120}
previewHeight={40}
previewBorderRadius="4px"
helperText="Wide image recommended, e.g. 1500x500 (optional)"
onError={setError}
/>
</div>
);
+34 -119
View File
@@ -1,13 +1,14 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { ArrowLeftIcon, CalendarIcon } from '@/components/Icons';
import { PostCard } from '@/components/PostCard';
import { User, Post } from '@/lib/types';
import AutoTextarea from '@/components/AutoTextarea';
import { Rocket, MoreHorizontal, Mail, Camera } from 'lucide-react';
import { UserStorageImageUpload } from '@/components/UserStorageImageUpload';
import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
@@ -114,68 +115,7 @@ export default function ProfilePage() {
const [isSaving, setIsSaving] = useState(false);
const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false);
const avatarInputRef = useRef<HTMLInputElement>(null);
const headerInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>, field: 'avatarUrl' | 'headerUrl') => {
const file = e.target.files?.[0];
if (!file) return;
// Reset inputs so change event fires again for same file
e.target.value = '';
setIsSaving(true);
try {
// 1. Upload the file
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/uploads', {
method: 'POST',
body: formData,
});
const uploadData = await uploadRes.json();
if (!uploadData.url) throw new Error('Upload failed');
const imageUrl = uploadData.url;
// 2. Update local form state immediately for UI feedback
setProfileForm(prev => ({ ...prev, [field]: imageUrl }));
// 3. Auto-save the profile change
if (!isIdentityUnlocked) {
throw new Error('Session expired. Please log in again.');
}
// Create partial update payload
const updatePayload: any = { [field]: imageUrl };
// Sign the action
const signedPayload = await signUserAction('update_profile', updatePayload);
const saveRes = await fetch('/api/auth/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedPayload),
});
const saveData = await saveRes.json();
if (!saveRes.ok) {
throw new Error(saveData.error || 'Failed to update profile');
}
// Update user state with the returned updated user
setUser(saveData.user);
// We do NOT exit edit mode automatically, allowing them to edit other fields
} catch (err) {
console.error(err);
setSaveError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setIsSaving(false);
}
};
const [storagePassword, setStoragePassword] = useState('');
useEffect(() => {
setIsEditing(false);
@@ -558,34 +498,8 @@ export default function ProfilePage() {
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
position: 'relative',
cursor: isEditing ? 'pointer' : 'default',
}}
onClick={() => {
if (isEditing) {
headerInputRef.current?.click();
}
}}
>
{isEditing && (
<div style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white'
}}>
<Camera size={32} />
<input
ref={headerInputRef}
type="file"
accept="image/*"
onChange={(e) => handleUpload(e, 'headerUrl')}
style={{ display: 'none' }}
/>
</div>
)}
</div>
{/* Avatar & Actions */}
@@ -604,12 +518,6 @@ export default function ProfilePage() {
border: '4px solid var(--background)',
marginTop: '-48px',
position: 'relative',
cursor: isEditing ? 'pointer' : 'default',
}}
onClick={() => {
if (isEditing) {
avatarInputRef.current?.click();
}
}}
>
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
@@ -617,28 +525,6 @@ export default function ProfilePage() {
) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
{isEditing && (
<div style={{
position: 'absolute',
inset: 0,
background: 'rgba(0,0,0,0.3)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white'
}}>
<Camera size={24} />
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => handleUpload(e, 'avatarUrl')}
style={{ display: 'none' }}
/>
</div>
)}
</div>
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
@@ -852,7 +738,36 @@ export default function ProfilePage() {
maxLength={100}
/>
</div>
{/* File inputs removed, now click-to-upload on visual elements */}
<UserStorageImageUpload
label="Avatar"
value={profileForm.avatarUrl}
onChange={(avatarUrl) => {
setSaveError(null);
setProfileForm({ ...profileForm, avatarUrl });
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48}
previewHeight={48}
previewBorderRadius="50%"
helperText="Square image recommended (optional)"
onError={(message) => setSaveError(message || null)}
/>
<UserStorageImageUpload
label="Header"
value={profileForm.headerUrl}
onChange={(headerUrl) => {
setSaveError(null);
setProfileForm({ ...profileForm, headerUrl });
}}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120}
previewHeight={40}
previewBorderRadius="4px"
helperText="Wide image recommended, e.g. 1500x500 (optional)"
onError={(message) => setSaveError(message || null)}
/>
{saveError && (
<div style={{ color: 'var(--error)', fontSize: '13px' }}>{saveError}</div>
)}