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 Link from 'next/link';
|
||||||
import AutoTextarea from '@/components/AutoTextarea';
|
import AutoTextarea from '@/components/AutoTextarea';
|
||||||
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
|
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
|
||||||
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||||
import { useToast } from '@/lib/contexts/ToastContext';
|
import { useToast } from '@/lib/contexts/ToastContext';
|
||||||
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
import { useAccentColor } from '@/lib/contexts/AccentColorContext';
|
||||||
import { refreshStorageSession } from '@/lib/storage/client';
|
import { refreshStorageSession } from '@/lib/storage/client';
|
||||||
@@ -30,6 +31,7 @@ export default function AdminPage() {
|
|||||||
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
const [isUploadingBanner, setIsUploadingBanner] = useState(false);
|
||||||
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
|
||||||
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
|
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
|
||||||
|
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = useState(false);
|
||||||
const [pendingBannerFile, setPendingBannerFile] = useState<File | null>(null);
|
const [pendingBannerFile, setPendingBannerFile] = useState<File | null>(null);
|
||||||
const [bannerPassword, setBannerPassword] = useState('');
|
const [bannerPassword, setBannerPassword] = useState('');
|
||||||
const [bannerPromptError, setBannerPromptError] = useState('');
|
const [bannerPromptError, setBannerPromptError] = useState('');
|
||||||
@@ -112,6 +114,11 @@ export default function AdminPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok || !data.url) {
|
if (!res.ok || !data.url) {
|
||||||
|
if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
|
||||||
|
setPendingBannerFile(file);
|
||||||
|
setShowBannerStorageConfiguration(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.status === 401 && allowPrompt) {
|
if (res.status === 401 && allowPrompt) {
|
||||||
setPendingBannerFile(file);
|
setPendingBannerFile(file);
|
||||||
setBannerPromptError('');
|
setBannerPromptError('');
|
||||||
@@ -607,6 +614,17 @@ export default function AdminPage() {
|
|||||||
onSubmit={handleBannerSessionSubmit}
|
onSubmit={handleBannerSessionSubmit}
|
||||||
onCancel={handleBannerSessionCancel}
|
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 { NextResponse } from 'next/server';
|
||||||
import { registerUser, createSession } from '@/lib/auth';
|
import { registerUser, createSession } from '@/lib/auth';
|
||||||
import { createStorageSession } from '@/lib/storage/session';
|
import { db, 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';
|
||||||
import { testS3Credentials } from '@/lib/storage/s3';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -13,14 +11,6 @@ const registerSchema = z.object({
|
|||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
displayName: z.string().optional(),
|
displayName: z.string().optional(),
|
||||||
turnstileToken: z.string().nullable().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) {
|
export async function POST(request: Request) {
|
||||||
@@ -28,8 +18,7 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Log registration attempt (excluding password)
|
// Log registration attempt (excluding password)
|
||||||
const { password, ...logData } = body;
|
console.log('Registration attempt:', { handle: body.handle, email: body.email });
|
||||||
console.log('Registration attempt details:', logData);
|
|
||||||
|
|
||||||
const data = registerSchema.parse(body);
|
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(
|
const user = await registerUser(
|
||||||
data.handle,
|
data.handle,
|
||||||
data.email,
|
data.email,
|
||||||
data.password,
|
data.password,
|
||||||
data.displayName,
|
data.displayName
|
||||||
data.storageProvider,
|
|
||||||
data.storageEndpoint || null,
|
|
||||||
data.storagePublicBaseUrl || null,
|
|
||||||
data.storageRegion,
|
|
||||||
data.storageBucket,
|
|
||||||
data.storageAccessKey,
|
|
||||||
data.storageSecretKey
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if this is an NSFW node and auto-enable NSFW settings
|
// 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
|
// 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,
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export async function POST(req: NextRequest) {
|
|||||||
// Check if user has S3 storage configured
|
// Check if user has S3 storage configured
|
||||||
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
|
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: 'Storage not configured. Please set up S3-compatible storage in your settings.'
|
error: 'Connect your storage before uploading media.',
|
||||||
}, { status: 400 });
|
code: 'STORAGE_NOT_CONFIGURED',
|
||||||
|
}, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageSession =
|
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 [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [handle, setHandle] = useState('');
|
const [handle, setHandle] = useState('');
|
||||||
const [displayName, setDisplayName] = 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 [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
const [nodeInfoLoaded, setNodeInfoLoaded] = useState(false);
|
||||||
@@ -262,13 +255,6 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
password,
|
password,
|
||||||
handle,
|
handle,
|
||||||
displayName,
|
displayName,
|
||||||
storageProvider,
|
|
||||||
storageEndpoint: storageEndpoint || null,
|
|
||||||
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
|
||||||
storageRegion,
|
|
||||||
storageBucket,
|
|
||||||
storageAccessKey,
|
|
||||||
storageSecretKey,
|
|
||||||
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
...(nodeInfo.turnstileSiteKey ? { turnstileToken } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -565,8 +551,7 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Email | Storage Provider */}
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
Email
|
Email
|
||||||
@@ -580,159 +565,11 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Storage Explainer Section */}
|
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '16px' }}>
|
||||||
<div style={{
|
You can connect your own S3-compatible storage when you first upload media.
|
||||||
marginBottom: '20px',
|
</p>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -812,11 +649,6 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
!password || password.length < 8 ||
|
!password || password.length < 8 ||
|
||||||
!confirmPassword ||
|
!confirmPassword ||
|
||||||
password !== confirmPassword ||
|
password !== confirmPassword ||
|
||||||
!storageProvider ||
|
|
||||||
!storageRegion ||
|
|
||||||
!storageBucket ||
|
|
||||||
!storageAccessKey ||
|
|
||||||
!storageSecretKey ||
|
|
||||||
(nodeInfo.isNsfw && !ageVerified)
|
(nodeInfo.isNsfw && !ageVerified)
|
||||||
))}
|
))}
|
||||||
>
|
>
|
||||||
@@ -882,8 +714,12 @@ export function AuthScreen({ modal = false, onClose, onSuccess }: AuthScreenProp
|
|||||||
Browse
|
Browse
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ImageIcon, AlertTriangle, Film } from 'lucide-react';
|
|||||||
import { VideoEmbed } from '@/components/VideoEmbed';
|
import { VideoEmbed } from '@/components/VideoEmbed';
|
||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||||
|
|
||||||
interface MediaAttachment extends Attachment {
|
interface MediaAttachment extends Attachment {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
@@ -28,6 +29,8 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const [pendingStorageFiles, setPendingStorageFiles] = useState<File[]>([]);
|
||||||
|
const [showStorageConfiguration, setShowStorageConfiguration] = useState(false);
|
||||||
const [linkPreview, setLinkPreview] = useState<any>(null);
|
const [linkPreview, setLinkPreview] = useState<any>(null);
|
||||||
const [fetchingPreview, setFetchingPreview] = useState(false);
|
const [fetchingPreview, setFetchingPreview] = useState(false);
|
||||||
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
const [lastDetectedUrl, setLastDetectedUrl] = useState<string | null>(null);
|
||||||
@@ -116,11 +119,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
setIsPosting(false);
|
setIsPosting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMediaSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const uploadMediaFiles = async (files: File[]) => {
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
event.target.value = '';
|
|
||||||
if (files.length === 0) return;
|
|
||||||
|
|
||||||
const remainingSlots = Math.max(0, 4 - attachments.length);
|
const remainingSlots = Math.max(0, 4 - attachments.length);
|
||||||
const selectedFiles = files.slice(0, remainingSlots);
|
const selectedFiles = files.slice(0, remainingSlots);
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
@@ -141,6 +140,12 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok || !data.media?.id) {
|
if (!res.ok || !data.media?.id) {
|
||||||
|
if (data.code === 'STORAGE_NOT_CONFIGURED') {
|
||||||
|
setPendingStorageFiles(selectedFiles);
|
||||||
|
setShowStorageConfiguration(true);
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(data.error || 'Upload failed');
|
throw new Error(data.error || 'Upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,12 +165,32 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMediaSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
event.target.value = '';
|
||||||
|
if (files.length === 0) return;
|
||||||
|
await uploadMediaFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveAttachment = (id: string) => {
|
const handleRemoveAttachment = (id: string) => {
|
||||||
setAttachments((prev) => prev.filter((item) => item.id !== id));
|
setAttachments((prev) => prev.filter((item) => item.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`compose ${isReply ? 'reply-compose' : ''}`}>
|
<div className={`compose ${isReply ? 'reply-compose' : ''}`}>
|
||||||
|
<StorageConfigurationPrompt
|
||||||
|
open={showStorageConfiguration}
|
||||||
|
onConfigured={async () => {
|
||||||
|
setShowStorageConfiguration(false);
|
||||||
|
const files = pendingStorageFiles;
|
||||||
|
setPendingStorageFiles([]);
|
||||||
|
await uploadMediaFiles(files);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowStorageConfiguration(false);
|
||||||
|
setPendingStorageFiles([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{replyingTo && !isReply && (
|
{replyingTo && !isReply && (
|
||||||
<div className="compose-reply-target">
|
<div className="compose-reply-target">
|
||||||
<div className="compose-reply-info">
|
<div className="compose-reply-info">
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface StorageConfigurationPromptProps {
|
||||||
|
open: boolean;
|
||||||
|
onConfigured: () => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StorageConfigurationPrompt({
|
||||||
|
open,
|
||||||
|
onConfigured,
|
||||||
|
onCancel,
|
||||||
|
}: StorageConfigurationPromptProps) {
|
||||||
|
const [provider, setProvider] = useState('r2');
|
||||||
|
const [endpoint, setEndpoint] = useState('');
|
||||||
|
const [publicBaseUrl, setPublicBaseUrl] = useState('');
|
||||||
|
const [region, setRegion] = useState('auto');
|
||||||
|
const [bucket, setBucket] = useState('');
|
||||||
|
const [accessKey, setAccessKey] = useState('');
|
||||||
|
const [secretKey, setSecretKey] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const needsCustomUrls = provider === 'r2' || provider === 'b2' || provider === 'contabo';
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/storage/configuration', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider,
|
||||||
|
endpoint: endpoint || null,
|
||||||
|
publicBaseUrl: publicBaseUrl || null,
|
||||||
|
region,
|
||||||
|
bucket,
|
||||||
|
accessKey,
|
||||||
|
secretKey,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to connect storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
await onConfigured();
|
||||||
|
} catch (submitError) {
|
||||||
|
setError(submitError instanceof Error ? submitError.message : 'Failed to connect storage');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 100000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect your media storage</h3>
|
||||||
|
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
|
||||||
|
Synapsis keeps media in your own S3-compatible bucket. This is only required when you upload a photo or video.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Provider</span>
|
||||||
|
<select className="input" value={provider} onChange={(event) => setProvider(event.target.value)}>
|
||||||
|
<option value="r2">Cloudflare R2</option>
|
||||||
|
<option value="b2">Backblaze B2</option>
|
||||||
|
<option value="wasabi">Wasabi</option>
|
||||||
|
<option value="contabo">Contabo S3</option>
|
||||||
|
<option value="s3">AWS S3</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{needsCustomUrls && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>S3 endpoint</span>
|
||||||
|
<input className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder="https://..." required />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Public media URL</span>
|
||||||
|
<input className="input" value={publicBaseUrl} onChange={(event) => setPublicBaseUrl(event.target.value)} placeholder="https://..." required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Region</span>
|
||||||
|
<input className="input" value={region} onChange={(event) => setRegion(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Bucket</span>
|
||||||
|
<input className="input" value={bucket} onChange={(event) => setBucket(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Access key ID</span>
|
||||||
|
<input className="input" type="password" value={accessKey} onChange={(event) => setAccessKey(event.target.value)} required minLength={10} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Secret access key</span>
|
||||||
|
<input className="input" type="password" value={secretKey} onChange={(event) => setSecretKey(event.target.value)} required minLength={10} />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Synapsis account password</span>
|
||||||
|
<input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||||
|
<span style={{ display: 'block', marginTop: '5px', fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Used locally to encrypt these credentials before they are saved.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>{isSubmitting ? 'Connecting...' : 'Connect and upload'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
|
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
|
||||||
|
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
|
||||||
import { refreshStorageSession } from '@/lib/storage/client';
|
import { refreshStorageSession } from '@/lib/storage/client';
|
||||||
|
|
||||||
interface UserStorageImageUploadProps {
|
interface UserStorageImageUploadProps {
|
||||||
@@ -28,6 +29,7 @@ export function UserStorageImageUpload({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [showSessionPrompt, setShowSessionPrompt] = useState(false);
|
const [showSessionPrompt, setShowSessionPrompt] = useState(false);
|
||||||
|
const [showConfigurationPrompt, setShowConfigurationPrompt] = useState(false);
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [promptError, setPromptError] = useState('');
|
const [promptError, setPromptError] = useState('');
|
||||||
@@ -55,6 +57,12 @@ export function UserStorageImageUpload({
|
|||||||
if (!res.ok || !data.url) {
|
if (!res.ok || !data.url) {
|
||||||
const message = data.error || 'Upload failed';
|
const message = data.error || 'Upload failed';
|
||||||
|
|
||||||
|
if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
|
||||||
|
setPendingFile(file);
|
||||||
|
setShowConfigurationPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (res.status === 401 && allowPrompt) {
|
if (res.status === 401 && allowPrompt) {
|
||||||
setPendingFile(file);
|
setPendingFile(file);
|
||||||
setPromptError('');
|
setPromptError('');
|
||||||
@@ -188,6 +196,18 @@ export function UserStorageImageUpload({
|
|||||||
onSubmit={handlePromptSubmit}
|
onSubmit={handlePromptSubmit}
|
||||||
onCancel={handlePromptCancel}
|
onCancel={handlePromptCancel}
|
||||||
/>
|
/>
|
||||||
|
<StorageConfigurationPrompt
|
||||||
|
open={showConfigurationPrompt}
|
||||||
|
onConfigured={async () => {
|
||||||
|
setShowConfigurationPrompt(false);
|
||||||
|
if (pendingFile) await uploadFile(pendingFile, false);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowConfigurationPrompt(false);
|
||||||
|
setPendingFile(null);
|
||||||
|
resetFileInput();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ export const users = sqliteTable('users', {
|
|||||||
movedTo: text('moved_to'), // New actor URL if this account migrated away
|
movedTo: text('moved_to'), // New actor URL if this account migrated away
|
||||||
movedFrom: text('moved_from'), // Old actor URL if this account migrated here
|
movedFrom: text('moved_from'), // Old actor URL if this account migrated here
|
||||||
migratedAt: integer('migrated_at', { mode: 'timestamp' }), // When the migration occurred
|
migratedAt: integer('migrated_at', { mode: 'timestamp' }), // When the migration occurred
|
||||||
// User-owned S3-compatible storage - required for new users
|
// Optional user-owned S3-compatible storage, configured on first media upload
|
||||||
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', 'contabo'
|
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', 'contabo'
|
||||||
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS)
|
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS)
|
||||||
storagePublicBaseUrl: text('storage_public_base_url'), // Public URL for viewing files (required for R2, B2, Contabo)
|
storagePublicBaseUrl: text('storage_public_base_url'), // Public URL for viewing files (required for R2, B2, Contabo)
|
||||||
|
|||||||
+1
-50
@@ -11,7 +11,6 @@ import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-k
|
|||||||
import { base58btc } from 'multiformats/bases/base58';
|
import { base58btc } from 'multiformats/bases/base58';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { upsertHandleEntries } from '@/lib/federation/handles';
|
import { upsertHandleEntries } from '@/lib/federation/handles';
|
||||||
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
|
|
||||||
|
|
||||||
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
|
const ACTIVE_SESSION_COOKIE_NAME = 'synapsis_session';
|
||||||
const SESSION_COOKIE_NAME = 'synapsis_sessions';
|
const SESSION_COOKIE_NAME = 'synapsis_sessions';
|
||||||
@@ -280,14 +279,7 @@ export async function registerUser(
|
|||||||
handle: string,
|
handle: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
displayName?: string,
|
displayName?: string
|
||||||
storageProvider?: string,
|
|
||||||
storageEndpoint?: string | null,
|
|
||||||
storagePublicBaseUrl?: string | null,
|
|
||||||
storageRegion?: string,
|
|
||||||
storageBucket?: string,
|
|
||||||
storageAccessKey?: string,
|
|
||||||
storageSecretKey?: string
|
|
||||||
): Promise<typeof users.$inferSelect> {
|
): Promise<typeof users.$inferSelect> {
|
||||||
// Validate handle format
|
// Validate handle format
|
||||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
|
if (!/^[a-zA-Z0-9_]{3,20}$/.test(handle)) {
|
||||||
@@ -312,23 +304,6 @@ export async function registerUser(
|
|||||||
throw new Error('Email is already registered');
|
throw new Error('Email is already registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate S3 storage credentials (required for new users)
|
|
||||||
if (!storageProvider) {
|
|
||||||
throw new Error('Storage provider is required.');
|
|
||||||
}
|
|
||||||
if (!storageRegion || storageRegion.length < 2) {
|
|
||||||
throw new Error('Storage region is required (e.g., us-east-1, auto).');
|
|
||||||
}
|
|
||||||
if (!storageBucket || storageBucket.length < 3) {
|
|
||||||
throw new Error('Storage bucket name is required.');
|
|
||||||
}
|
|
||||||
if (!storageAccessKey || storageAccessKey.length < 10) {
|
|
||||||
throw new Error('Storage access key is required.');
|
|
||||||
}
|
|
||||||
if (!storageSecretKey || storageSecretKey.length < 10) {
|
|
||||||
throw new Error('Storage secret key is required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate cryptographic keys
|
// Generate cryptographic keys
|
||||||
const { publicKey, privateKey } = await generateKeyPair();
|
const { publicKey, privateKey } = await generateKeyPair();
|
||||||
|
|
||||||
@@ -341,38 +316,14 @@ export async function registerUser(
|
|||||||
|
|
||||||
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
|
||||||
|
|
||||||
// Generate avatar and upload to user's S3 storage
|
|
||||||
const fullHandle = `${handle.toLowerCase()}@${nodeDomain}`;
|
|
||||||
const avatarUrl = await generateAndUploadAvatarToUserStorage(
|
|
||||||
fullHandle,
|
|
||||||
storageEndpoint || undefined,
|
|
||||||
storagePublicBaseUrl || undefined,
|
|
||||||
storageRegion,
|
|
||||||
storageBucket,
|
|
||||||
storageAccessKey,
|
|
||||||
storageSecretKey
|
|
||||||
);
|
|
||||||
|
|
||||||
// Encrypt the storage credentials with user's password
|
|
||||||
const encryptedAccessKey = encryptPrivateKey(storageAccessKey, password);
|
|
||||||
const encryptedSecretKey = encryptPrivateKey(storageSecretKey, password);
|
|
||||||
|
|
||||||
const [user] = await db.insert(users).values({
|
const [user] = await db.insert(users).values({
|
||||||
did,
|
did,
|
||||||
handle: handle.toLowerCase(),
|
handle: handle.toLowerCase(),
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
displayName: displayName || handle,
|
displayName: displayName || handle,
|
||||||
avatarUrl,
|
|
||||||
publicKey,
|
publicKey,
|
||||||
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
privateKeyEncrypted: serializeEncryptedKey(encryptedPrivateKey),
|
||||||
storageProvider,
|
|
||||||
storageEndpoint: storageEndpoint || null,
|
|
||||||
storagePublicBaseUrl: storagePublicBaseUrl || null,
|
|
||||||
storageRegion,
|
|
||||||
storageBucket,
|
|
||||||
storageAccessKeyEncrypted: serializeEncryptedKey(encryptedAccessKey),
|
|
||||||
storageSecretKeyEncrypted: serializeEncryptedKey(encryptedSecretKey),
|
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
await upsertHandleEntries([{
|
await upsertHandleEntries([{
|
||||||
|
|||||||
Reference in New Issue
Block a user