Defer user-owned S3 setup until first media upload
Hop-State: A_06FP6DP9ENXP2WBSR2YPNA8 Hop-Proposal: R_06FP6DM6S5P96EFX90PH2F8 Hop-Task: T_06FP6C52Z3JHQBQ5XNNHZ3R Hop-Attempt: AT_06FP6C52Z14V6CZJQTJN4PR
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
|
||||
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false);
|
||||
const [pendingBannerFile, setPendingBannerFile] = useState<File | null>(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}
|
||||
/>
|
||||
<StorageConfigurationPrompt
|
||||
open={showBannerStorageConfiguration}
|
||||
onConfigured={async () => {
|
||||
setShowBannerStorageConfiguration(false);
|
||||
if (pendingBannerFile) await uploadBannerFile(pendingBannerFile, false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowBannerStorageConfiguration(false);
|
||||
setPendingBannerFile(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
+10
-174
@@ -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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Email | Storage Provider */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Email
|
||||
@@ -580,159 +565,11 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Storage Provider
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
value={storageProvider}
|
||||
onChange={(e) => setStorageProvider(e.target.value)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<option value="r2">Cloudflare R2 (10GB free)</option>
|
||||
<option value="b2">Backblaze B2 (10GB free)</option>
|
||||
<option value="wasabi">Wasabi</option>
|
||||
<option value="contabo">Contabo S3</option>
|
||||
<option value="s3">AWS S3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Explainer Section */}
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '16px',
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, marginBottom: '12px', color: 'var(--foreground)' }}>
|
||||
🗄️ Why do I need to provide storage?
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '12px' }}>
|
||||
Synapsis is <strong>decentralized</strong> — 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.
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||
<strong style={{ color: 'var(--success)' }}>✓ You own your data</strong><br/>
|
||||
Your files stay in your bucket, not ours
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||
<strong style={{ color: 'var(--success)' }}>✓ Portable</strong><br/>
|
||||
Take your media with you if you leave
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)' }}>
|
||||
<strong style={{ color: 'var(--success)' }}>✓ Free options</strong><br/>
|
||||
R2 and B2 offer 10GB free
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL - only show for providers that need it (R2, B2, Contabo) */}
|
||||
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo') && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Endpoint URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storageEndpoint}
|
||||
onChange={(e) => setStorageEndpoint(e.target.value)}
|
||||
placeholder={storageProvider === 'contabo' ? 'https://s3.eu2.contabo.com' : 'https://<account>.r2.cloudflarestorage.com'}
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{storageProvider === 'contabo' ? 'S3 API endpoint from Contabo dashboard (e.g., https://s3.eu2.contabo.com)' :
|
||||
'S3-compatible endpoint URL for uploads.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Public Base URL - only show for providers that need it (R2, B2, Contabo) */}
|
||||
{(storageProvider === 'r2' || storageProvider === 'b2' || storageProvider === 'contabo') && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Public Base URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storagePublicBaseUrl}
|
||||
onChange={(e) => setStoragePublicBaseUrl(e.target.value)}
|
||||
placeholder={storageProvider === 'contabo' ? 'https://usc1.contabostorage.com/d5bec82ae49b444d8314dcb13654dd1d:your-bucket' : 'https://pub-xxx.r2.dev'}
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '11px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
{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)'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Region
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storageRegion}
|
||||
onChange={(e) => setStorageRegion(e.target.value)}
|
||||
placeholder="auto"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Bucket Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={storageBucket}
|
||||
onChange={(e) => setStorageBucket(e.target.value)}
|
||||
placeholder="my-synapsis-bucket"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Access Key ID
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={storageAccessKey}
|
||||
onChange={(e) => setStorageAccessKey(e.target.value)}
|
||||
placeholder="AKIA..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
Secret Access Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={storageSecretKey}
|
||||
onChange={(e) => setStorageSecretKey(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginTop: '4px' }}>
|
||||
Get free S3 storage at <a href="https://dash.cloudflare.com/sign-up/r2" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Cloudflare R2</a> (10GB free) or <a href="https://www.backblaze.com/b2/sign-up.html" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Backblaze B2</a>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '16px' }}>
|
||||
You can connect your own S3-compatible storage when you first upload media.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '16px' }}>
|
||||
You can connect your own S3-compatible storage when you first upload media.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||
|
||||
Reference in New Issue
Block a user