diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index d271b33..99a6fca 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import AutoTextarea from '@/components/AutoTextarea'; import { StorageSessionPrompt } from '@/components/StorageSessionPrompt'; +import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { useToast } from '@/lib/contexts/ToastContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { refreshStorageSession } from '@/lib/storage/client'; @@ -30,6 +31,7 @@ export default function AdminPage() { const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [bannerUploadError, setBannerUploadError] = useState(null); const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false); + const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false); const [pendingBannerFile, setPendingBannerFile] = useState(null); const [bannerPassword, setBannerPassword] = useState(''); const [bannerPromptError, setBannerPromptError] = useState(''); @@ -112,6 +114,11 @@ export default function AdminPage() { const data = await res.json(); if (!res.ok || !data.url) { + if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) { + setPendingBannerFile(file); + setShowBannerStorageConfiguration(true); + return; + } if (res.status === 401 && allowPrompt) { setPendingBannerFile(file); setBannerPromptError(''); @@ -607,6 +614,17 @@ export default function AdminPage() { onSubmit={handleBannerSessionSubmit} onCancel={handleBannerSessionCancel} /> + { + setShowBannerStorageConfiguration(false); + if (pendingBannerFile) await uploadBannerFile(pendingBannerFile, false); + }} + onCancel={() => { + setShowBannerStorageConfiguration(false); + setPendingBannerFile(null); + }} + /> ); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index a0d96b9..63b2aa9 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,10 +1,8 @@ import { NextResponse } from 'next/server'; import { registerUser, createSession } from '@/lib/auth'; -import { createStorageSession } from '@/lib/storage/session'; -import { db, nodes, users } from '@/db'; +import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { verifyTurnstileToken } from '@/lib/turnstile'; -import { testS3Credentials } from '@/lib/storage/s3'; import { z } from 'zod'; const registerSchema = z.object({ @@ -13,14 +11,6 @@ const registerSchema = z.object({ password: z.string().min(8), displayName: z.string().optional(), turnstileToken: z.string().nullable().optional(), - // S3-compatible storage credentials - storageProvider: z.string().min(1), - storageEndpoint: z.string().nullable().optional(), - storagePublicBaseUrl: z.string().nullable().optional(), - storageRegion: z.string().min(1), - storageBucket: z.string().min(1), - storageAccessKey: z.string().min(10), - storageSecretKey: z.string().min(10), }); export async function POST(request: Request) { @@ -28,8 +18,7 @@ export async function POST(request: Request) { const body = await request.json(); // Log registration attempt (excluding password) - const { password, ...logData } = body; - console.log('Registration attempt details:', logData); + console.log('Registration attempt:', { handle: body.handle, email: body.email }); const data = registerSchema.parse(body); @@ -46,38 +35,11 @@ export async function POST(request: Request) { } } - // Test S3 credentials before creating account - console.log('[Register] Testing S3 credentials...'); - const s3Test = await testS3Credentials( - data.storageEndpoint || null, - data.storageRegion, - data.storageBucket, - data.storageAccessKey, - data.storageSecretKey - ); - - if (!s3Test.success) { - console.error('[Register] S3 credential test failed:', s3Test.error); - return NextResponse.json( - { error: `Storage connection failed: ${s3Test.error}` }, - { status: 400 } - ); - } - - console.log('[Register] S3 credentials verified successfully'); - const user = await registerUser( data.handle, data.email, data.password, - data.displayName, - data.storageProvider, - data.storageEndpoint || null, - data.storagePublicBaseUrl || null, - data.storageRegion, - data.storageBucket, - data.storageAccessKey, - data.storageSecretKey + data.displayName ); // Check if this is an NSFW node and auto-enable NSFW settings @@ -98,7 +60,6 @@ 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/media/upload/route.ts b/src/app/api/media/upload/route.ts index 242b47f..63e6376 100644 --- a/src/app/api/media/upload/route.ts +++ b/src/app/api/media/upload/route.ts @@ -44,8 +44,9 @@ export async function POST(req: NextRequest) { // Check if user has S3 storage configured if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) { return NextResponse.json({ - error: 'Storage not configured. Please set up S3-compatible storage in your settings.' - }, { status: 400 }); + error: 'Connect your storage before uploading media.', + code: 'STORAGE_NOT_CONFIGURED', + }, { status: 409 }); } const storageSession = diff --git a/src/app/api/storage/configuration/route.ts b/src/app/api/storage/configuration/route.ts new file mode 100644 index 0000000..c77c854 --- /dev/null +++ b/src/app/api/storage/configuration/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { db, users } from '@/db'; +import { requireAuth, verifyPassword } from '@/lib/auth'; +import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; +import { testS3Credentials, type StorageProvider } from '@/lib/storage/s3'; +import { createStorageSession } from '@/lib/storage/session'; + +const configurationSchema = z.object({ + password: z.string().min(1), + provider: z.enum(['s3', 'r2', 'b2', 'wasabi', 'contabo']), + endpoint: z.string().trim().nullable().optional(), + publicBaseUrl: z.string().trim().nullable().optional(), + region: z.string().trim().min(2), + bucket: z.string().trim().min(3), + accessKey: z.string().min(10), + secretKey: z.string().min(10), +}); + +export async function POST(request: Request) { + try { + const user = await requireAuth(); + const data = configurationSchema.parse(await request.json()); + + if (!user.passwordHash || !(await verifyPassword(data.password, user.passwordHash))) { + return NextResponse.json({ error: 'Incorrect account password' }, { status: 401 }); + } + + const storageTest = await testS3Credentials( + data.endpoint || null, + data.region, + data.bucket, + data.accessKey, + data.secretKey + ); + + if (!storageTest.success) { + return NextResponse.json( + { error: `Storage connection failed: ${storageTest.error}` }, + { status: 400 } + ); + } + + const storageProvider = data.provider as StorageProvider; + const storageAccessKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.accessKey, data.password)); + const storageSecretKeyEncrypted = serializeEncryptedKey(encryptPrivateKey(data.secretKey, data.password)); + const storageValues = { + storageProvider, + storageEndpoint: data.endpoint || null, + storagePublicBaseUrl: data.publicBaseUrl || null, + storageRegion: data.region, + storageBucket: data.bucket, + storageAccessKeyEncrypted, + storageSecretKeyEncrypted, + }; + + await db.update(users).set(storageValues).where(eq(users.id, user.id)); + await createStorageSession({ ...user, ...storageValues }, data.password); + + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: 'Invalid storage configuration', details: error.issues }, { status: 400 }); + } + if (error instanceof Error && error.message === 'Authentication required') { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); + } + + console.error('Storage configuration error:', error); + return NextResponse.json({ error: 'Failed to configure storage' }, { status: 500 }); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index e81774d..ca848f3 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -38,13 +38,6 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp const [confirmPassword, setConfirmPassword] = useState(''); const [handle, setHandle] = useState(''); const [displayName, setDisplayName] = useState(''); - const [storageProvider, setStorageProvider] = useState('r2'); - const [storageEndpoint, setStorageEndpoint] = useState(''); - const [storagePublicBaseUrl, setStoragePublicBaseUrl] = useState(''); - const [storageRegion, setStorageRegion] = useState('auto'); - const [storageBucket, setStorageBucket] = useState(''); - const [storageAccessKey, setStorageAccessKey] = useState(''); - const [storageSecretKey, setStorageSecretKey] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false); @@ -262,13 +255,6 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp password, handle, displayName, - storageProvider, - storageEndpoint: storageEndpoint || null, - storagePublicBaseUrl: storagePublicBaseUrl || null, - storageRegion, - storageBucket, - storageAccessKey, - storageSecretKey, ...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {}) }; @@ -565,8 +551,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp - {/* Row 3: Email | Storage Provider */} -
+
-
- - -
- {/* Storage Explainer Section */} -
-
- 🗄️ Why do I need to provide storage? -
-
- Synapsis is decentralized — unlike other platforms, we don't store your photos, videos, or files on our servers. - Instead, you connect your own S3-compatible storage bucket (like Cloudflare R2 or Backblaze B2) where your media lives. -
-
-
- ✓ You own your data
- Your files stay in your bucket, not ours -
-
- ✓ Portable
- Take your media with you if you leave -
-
- ✓ Free options
- R2 and B2 offer 10GB free -
-
-
- - {/* Endpoint URL - only show for providers that need it (R2, B2, Contabo) */} - {(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo') && ( -
- - setStorageEndpoint(e.target.value)} - placeholder={storageProvider === 'contabo' ? 'https://s3.eu2.contabo.com' : 'https://.r2.cloudflarestorage.com'} - required - /> -
- {storageProvider === 'contabo' ? 'S3 API endpoint from Contabo dashboard (e.g., https://s3.eu2.contabo.com)' : - 'S3-compatible endpoint URL for uploads.'} -
-
- )} - - {/* Public Base URL - only show for providers that need it (R2, B2, Contabo) */} - {(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo') && ( -
- - setStoragePublicBaseUrl(e.target.value)} - placeholder={storageProvider === 'contabo' ? 'https://usc1.contabostorage.com/d5bec82ae49b444d8314dcb13654dd1d:your-bucket' : 'https://pub-xxx.r2.dev'} - required - /> -
- {storageProvider === 'contabo' ? 'Public URL from Contabo (format: https://region.contabostorage.com/account-id:bucket)' : - storageProvider === 'r2' ? 'Public bucket URL from R2 dashboard (e.g., https://pub-xxx.r2.dev)' : - storageProvider === 'b2' ? 'Public bucket URL from B2 dashboard (e.g., https://f000.backblazeb2.com/file/bucket)' : - 'Public URL where files are accessible (without trailing slash)'} -
-
- )} - -
-
- - setStorageRegion(e.target.value)} - placeholder="auto" - required - /> -
-
- - setStorageBucket(e.target.value)} - placeholder="my-synapsis-bucket" - required - /> -
-
- -
- - setStorageAccessKey(e.target.value)} - placeholder="AKIA..." - required - /> -
- -
- - setStorageSecretKey(e.target.value)} - placeholder="••••••••" - required - /> -
- Get free S3 storage at Cloudflare R2 (10GB free) or Backblaze B2 -
-
+

+ You can connect your own S3-compatible storage when you first upload media. +

)} @@ -812,11 +649,6 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp !password || password.length < 8 || !confirmPassword || password !== confirmPassword || - !storageProvider || - !storageRegion || - !storageBucket || - !storageAccessKey || - !storageSecretKey || (nodeInfo.isNsfw && !ageVerified) ))} > @@ -882,8 +714,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp Browse -
- + + + +

+ You can connect your own S3-compatible storage when you first upload media. +