Make user media uploads session-backed
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user