Make user media uploads session-backed

This commit is contained in:
cyph3rasi
2026-03-07 18:14:02 -08:00
parent 7422e01a22
commit 28eb46d653
13 changed files with 340 additions and 285 deletions
+1 -1
View File
@@ -121,7 +121,7 @@ export default function AdminPage() {
await handleSaveSettings(nextSettings); await handleSaveSettings(nextSettings);
} catch (error) { } catch (error) {
console.error('Banner upload failed', 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 { } finally {
setIsUploadingBanner(false); setIsUploadingBanner(false);
} }
+2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { authenticateUser, createSession } from '@/lib/auth'; import { authenticateUser, createSession } from '@/lib/auth';
import { createStorageSession } from '@/lib/storage/session';
import { verifyTurnstileToken } from '@/lib/turnstile'; import { verifyTurnstileToken } from '@/lib/turnstile';
import { z } from 'zod'; import { z } from 'zod';
@@ -30,6 +31,7 @@ export async function POST(request: Request) {
// Create session // Create session
await createSession(user.id); await createSession(user.id);
await createStorageSession(user, data.password);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+2
View File
@@ -1,8 +1,10 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth'; import { destroySession } from '@/lib/auth';
import { clearStorageSession } from '@/lib/storage/session';
export async function POST() { export async function POST() {
try { try {
await clearStorageSession();
await destroySession(); await destroySession();
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
+2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { registerUser, createSession } from '@/lib/auth'; import { registerUser, createSession } from '@/lib/auth';
import { createStorageSession } from '@/lib/storage/session';
import { db, nodes, users } from '@/db'; import { db, nodes, users } from '@/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { verifyTurnstileToken } from '@/lib/turnstile'; import { verifyTurnstileToken } from '@/lib/turnstile';
@@ -97,6 +98,7 @@ export async function POST(request: Request) {
// Create session for new user // Create session for new user
await createSession(user.id); await createSession(user.id);
await createStorageSession(user, data.password);
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+15 -18
View File
@@ -17,7 +17,8 @@ import {
BotHandleTakenError, BotHandleTakenError,
BotValidationError, BotValidationError,
} from '@/lib/bots/botManager'; } 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 // Schema for creating a bot
const createBotSchema = z.object({ const createBotSchema = z.object({
@@ -43,7 +44,6 @@ const createBotSchema = z.object({
timezone: z.string().optional(), timezone: z.string().optional(),
}).optional(), }).optional(),
autonomousMode: z.boolean().optional(), autonomousMode: z.boolean().optional(),
// Optional: password to generate avatar using owner's S3 storage
ownerPassword: z.string().optional(), ownerPassword: z.string().optional(),
}); });
@@ -61,28 +61,25 @@ export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
const data = createBotSchema.parse(body); 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; let botAvatarUrl: string | null | undefined = data.avatarUrl;
if (!botAvatarUrl && data.ownerPassword && user.storageAccessKeyEncrypted && user.storageSecretKeyEncrypted && user.storageBucket) { if (!botAvatarUrl && storageSession) {
try { try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`; const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
// Decrypt the storage credentials
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
user.storageAccessKeyEncrypted,
user.storageSecretKeyEncrypted,
data.ownerPassword
);
botAvatarUrl = await generateAndUploadAvatarToUserStorage( botAvatarUrl = await generateAndUploadAvatarToUserStorage(
botHandle, botHandle,
user.storageEndpoint || undefined, storageSession.endpoint || undefined,
user.storagePublicBaseUrl || undefined, storageSession.publicBaseUrl || undefined,
user.storageRegion || 'auto', storageSession.region || 'us-east-1',
user.storageBucket, storageSession.bucket,
accessKeyId, storageSession.accessKeyId,
secretAccessKey storageSession.secretAccessKey
); );
} catch (err) { } catch (err) {
console.error('[Bot API] Failed to generate bot avatar:', err); console.error('[Bot API] Failed to generate bot avatar:', err);
+17 -14
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db'; import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth'; 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'; import { v4 as uuidv4 } from 'uuid';
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images
@@ -47,10 +48,13 @@ export async function POST(req: NextRequest) {
}, { status: 400 }); }, { status: 400 });
} }
// Require password to decrypt storage credentials const storageSession =
if (!password) { (await getStorageSession(user.id)) ||
return NextResponse.json({ (password ? await createStorageSession(user, password) : null);
error: 'Password required to upload media. Your storage credentials are encrypted and need your password to decrypt.'
if (!storageSession) {
return NextResponse.json({
error: 'Upload session expired. Please sign in again.'
}, { status: 401 }); }, { status: 401 });
} }
@@ -58,18 +62,17 @@ export async function POST(req: NextRequest) {
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`; const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// Upload to user's own S3-compatible storage // Upload to user's own S3-compatible storage
const uploadResult = await uploadToUserStorage( const uploadResult = await uploadWithStorageCredentials(
buffer, buffer,
filename, filename,
file.type, file.type,
user.storageProvider as any, storageSession.provider,
user.storageEndpoint, storageSession.endpoint,
user.storagePublicBaseUrl, storageSession.publicBaseUrl,
user.storageRegion || 'us-east-1', storageSession.region,
user.storageBucket || '', storageSession.bucket,
user.storageAccessKeyEncrypted, storageSession.accessKeyId,
user.storageSecretKeyEncrypted, storageSession.secretAccessKey
password
); );
// Store media record with S3 URL // Store media record with S3 URL
+44
View File
@@ -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 });
}
-5
View File
@@ -47,7 +47,6 @@ export default function EditBotPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [storagePassword, setStoragePassword] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -371,8 +370,6 @@ export default function EditBotPage() {
setError(''); setError('');
setFormData(prev => ({ ...prev, avatarUrl })); setFormData(prev => ({ ...prev, avatarUrl }));
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48} previewWidth={48}
previewHeight={48} previewHeight={48}
previewBorderRadius="50%" previewBorderRadius="50%"
@@ -387,8 +384,6 @@ export default function EditBotPage() {
setError(''); setError('');
setFormData(prev => ({ ...prev, headerUrl })); setFormData(prev => ({ ...prev, headerUrl }));
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120} previewWidth={120}
previewHeight={40} previewHeight={40}
previewBorderRadius="4px" previewBorderRadius="4px"
-6
View File
@@ -40,7 +40,6 @@ export default function NewBotPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity'); const [step, setStep] = useState<'identity' | 'personality' | 'sources' | 'schedule'>('identity');
const [storagePassword, setStoragePassword] = useState('');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -183,7 +182,6 @@ export default function NewBotPage() {
llmProvider: formData.llmProvider, llmProvider: formData.llmProvider,
llmModel: formData.llmModel, llmModel: formData.llmModel,
llmApiKey: formData.llmApiKey, llmApiKey: formData.llmApiKey,
ownerPassword: storagePassword || undefined,
autonomousMode: formData.autonomousMode, autonomousMode: formData.autonomousMode,
schedule: formData.autonomousMode ? { schedule: formData.autonomousMode ? {
type: 'interval', type: 'interval',
@@ -321,8 +319,6 @@ export default function NewBotPage() {
setError(''); setError('');
setFormData(prev => ({ ...prev, avatarUrl })); setFormData(prev => ({ ...prev, avatarUrl }));
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48} previewWidth={48}
previewHeight={48} previewHeight={48}
previewBorderRadius="50%" previewBorderRadius="50%"
@@ -337,8 +333,6 @@ export default function NewBotPage() {
setError(''); setError('');
setFormData(prev => ({ ...prev, headerUrl })); setFormData(prev => ({ ...prev, headerUrl }));
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120} previewWidth={120}
previewHeight={40} previewHeight={40}
previewBorderRadius="4px" previewBorderRadius="4px"
-6
View File
@@ -115,8 +115,6 @@ export default function ProfilePage() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isBlocked, setIsBlocked] = useState(false); const [isBlocked, setIsBlocked] = useState(false);
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [storagePassword, setStoragePassword] = useState('');
useEffect(() => { useEffect(() => {
setIsEditing(false); setIsEditing(false);
setSaveError(null); setSaveError(null);
@@ -745,8 +743,6 @@ export default function ProfilePage() {
setSaveError(null); setSaveError(null);
setProfileForm({ ...profileForm, avatarUrl }); setProfileForm({ ...profileForm, avatarUrl });
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={48} previewWidth={48}
previewHeight={48} previewHeight={48}
previewBorderRadius="50%" previewBorderRadius="50%"
@@ -760,8 +756,6 @@ export default function ProfilePage() {
setSaveError(null); setSaveError(null);
setProfileForm({ ...profileForm, headerUrl }); setProfileForm({ ...profileForm, headerUrl });
}} }}
password={storagePassword}
onPasswordChange={setStoragePassword}
previewWidth={120} previewWidth={120}
previewHeight={40} previewHeight={40}
previewBorderRadius="4px" previewBorderRadius="4px"
+62 -194
View File
@@ -1,13 +1,11 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
interface UserStorageImageUploadProps { interface UserStorageImageUploadProps {
label: string; label: string;
value: string; value: string;
onChange: (url: string) => void; onChange: (url: string) => void;
password: string;
onPasswordChange: (password: string) => void;
helperText?: string; helperText?: string;
previewWidth?: number; previewWidth?: number;
previewHeight?: number; previewHeight?: number;
@@ -19,8 +17,6 @@ export function UserStorageImageUpload({
label, label,
value, value,
onChange, onChange,
password,
onPasswordChange,
helperText, helperText,
previewWidth = 48, previewWidth = 48,
previewHeight = 48, previewHeight = 48,
@@ -29,27 +25,6 @@ export function UserStorageImageUpload({
}: UserStorageImageUploadProps) { }: UserStorageImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(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 = () => { const resetFileInput = () => {
if (inputRef.current) { if (inputRef.current) {
@@ -57,14 +32,12 @@ export function UserStorageImageUpload({
} }
}; };
const uploadFile = async (file: File, uploadPassword: string) => { const uploadFile = async (file: File) => {
setIsUploading(true); setIsUploading(true);
setPromptError('');
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('password', uploadPassword);
const res = await fetch('/api/media/upload', { const res = await fetch('/api/media/upload', {
method: 'POST', method: 'POST',
@@ -76,25 +49,16 @@ export function UserStorageImageUpload({
const message = data.error || 'Upload failed'; const message = data.error || 'Upload failed';
if (res.status === 401) { if (res.status === 401) {
onPasswordChange(''); throw new Error(message || 'Upload session expired. Please sign in again.');
setPasswordInput('');
setPendingFile(file);
setShowPasswordPrompt(true);
setPromptError(message);
return;
} }
throw new Error(message); throw new Error(message);
} }
onPasswordChange(uploadPassword);
onChange(data.media?.url || data.url); onChange(data.media?.url || data.url);
onError?.(''); onError?.('');
setPendingFile(null);
setShowPasswordPrompt(false);
setPasswordInput(uploadPassword);
} catch (error) { } catch (error) {
reportError(error instanceof Error ? error.message : 'Upload failed'); onError?.(error instanceof Error ? error.message : 'Upload failed');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
resetFileInput(); resetFileInput();
@@ -104,169 +68,73 @@ export function UserStorageImageUpload({
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
await uploadFile(file);
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();
}; };
return ( return (
<> <div>
<div> <label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}>
<label style={{ display: 'block', fontSize: '14px', fontWeight: 500, marginBottom: '8px' }}> {label}
{label} </label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}>
{isUploading ? 'Uploading...' : 'Choose File'}
<input
ref={inputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isUploading}
style={{ display: 'none' }}
/>
</label> </label>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm" style={{ cursor: isUploading ? 'default' : 'pointer' }}> {value && (
{isUploading ? 'Uploading...' : 'Choose File'} <div
<input style={{
ref={inputRef} width: `${previewWidth}px`,
type="file" height: `${previewHeight}px`,
accept="image/*" borderRadius: previewBorderRadius,
onChange={handleFileChange} overflow: 'hidden',
disabled={isUploading} border: '1px solid var(--border)',
style={{ display: 'none' }} background: 'var(--background-tertiary)',
}}
>
<img
src={value}
alt={`${label} preview`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/> />
</label> </div>
)}
{value && ( {value && (
<div <button
style={{ type="button"
width: `${previewWidth}px`, onClick={() => onChange('')}
height: `${previewHeight}px`, className="btn btn-ghost btn-sm"
borderRadius: previewBorderRadius, style={{ color: 'var(--error)' }}
overflow: 'hidden', >
border: '1px solid var(--border)', Remove
background: 'var(--background-tertiary)', </button>
}}
>
<img
src={value}
alt={`${label} preview`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
)}
{value && (
<button
type="button"
onClick={() => onChange('')}
className="btn btn-ghost btn-sm"
style={{ color: 'var(--error)' }}
>
Remove
</button>
)}
</div>
{helperText && (
<p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
{helperText}
</p>
)} )}
</div> </div>
{showPasswordPrompt && ( {helperText && (
<div <p style={{ fontSize: '13px', color: 'var(--foreground-tertiary)', marginTop: '6px' }}>
style={{ {helperText}
position: 'fixed', </p>
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
padding: '20px',
}}
onClick={handleCancel}
>
<div
className="card"
style={{ width: '100%', maxWidth: '420px', padding: '20px' }}
onClick={(event) => event.stopPropagation()}
>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
Unlock storage upload
</h3>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px', lineHeight: 1.5 }}>
Enter your account password to upload this image to your own storage.
</p>
<form onSubmit={handlePasswordSubmit}>
<div style={{ marginBottom: '12px' }}>
<label
htmlFor="storage-upload-password"
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 500,
}}
>
Password
</label>
<input
id="storage-upload-password"
type="password"
className="input"
value={passwordInput}
onChange={(event) => {
setPasswordInput(event.target.value);
setPromptError('');
}}
autoFocus
/>
</div>
{promptError && (
<div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>
{promptError}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" className="btn btn-ghost" onClick={handleCancel} disabled={isUploading}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Upload'}
</button>
</div>
</form>
</div>
</div>
)} )}
</>
<p
style={{
fontSize: '12px',
color: 'var(--foreground-tertiary)',
marginTop: '6px',
}}
>
If uploads stop working after a long session, sign in again to refresh your upload access.
</p>
</div>
); );
} }
+59 -41
View File
@@ -22,6 +22,24 @@ interface StorageUploadResult {
key: string; 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 * Decrypt S3 credentials from encrypted storage
*/ */
@@ -76,23 +94,48 @@ export async function uploadToUserStorage(
encryptedSecretKey: string, encryptedSecretKey: string,
password: string password: string
): Promise<StorageUploadResult> { ): Promise<StorageUploadResult> {
// Decrypt credentials
const { accessKeyId, secretAccessKey } = decryptS3Credentials( const { accessKeyId, secretAccessKey } = decryptS3Credentials(
encryptedAccessKey, encryptedAccessKey,
encryptedSecretKey, encryptedSecretKey,
password 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<StorageUploadResult> {
const normalizedRegion = region || 'us-east-1';
const s3 = createS3Client({ const s3 = createS3Client({
endpoint: endpoint || undefined, endpoint: endpoint || undefined,
region, region: normalizedRegion,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
bucket, bucket,
}); });
// Upload file
const key = `synapsis/${filename}`; const key = `synapsis/${filename}`;
await s3.send(new PutObjectCommand({ await s3.send(new PutObjectCommand({
@@ -102,19 +145,7 @@ export async function uploadToUserStorage(
ContentType: mimeType, ContentType: mimeType,
})); }));
// Construct URL based on provider const url = buildStorageUrl(key, endpoint, publicBaseUrl, normalizedRegion, bucket);
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}`;
}
return { url, key }; return { url, key };
} }
@@ -193,33 +224,20 @@ export async function generateAndUploadAvatarToUserStorage(
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
// 2. Upload to user's S3 const result = await uploadWithStorageCredentials(
const s3 = createS3Client({ buffer,
endpoint: endpoint || undefined, `avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`,
'image/png',
's3',
endpoint || null,
publicBaseUrl || null,
region, region,
accessKeyId: accessKey,
secretAccessKey: secretKey,
bucket, bucket,
}); accessKey,
secretKey
);
const key = `synapsis/avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`; return result.url;
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}`;
} catch (error) { } catch (error) {
console.error('Error generating/uploading avatar:', error); console.error('Error generating/uploading avatar:', error);
+136
View File
@@ -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<StorageSessionPayload | null> {
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<StorageSessionPayload | null> {
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<void> {
const cookieStore = await cookies();
cookieStore.delete(STORAGE_SESSION_COOKIE);
}