Make Stuffbox the exclusive media provider and simplify encrypted messaging copy

Hop-State: A_06FPCP16ZEAQDRH1JF5JRZG
Hop-Proposal: R_06FPCP0618KWV4X8SK82048
Hop-Task: T_06FPCM7MFZHQDCR4JBRHMG0
Hop-Attempt: AT_06FPCM7MFW92T7J4T312R08
This commit is contained in:
2026-07-15 08:14:41 -07:00
committed by Hop
parent 566d79347f
commit 9dc416b5f5
24 changed files with 143 additions and 2732 deletions
+2 -4
View File
@@ -61,9 +61,7 @@ Uninstalling preserves the database and environment by default. Pass `--purge-da
The node database is a local embedded Turso/SQLite file. Media remains in storage controlled by each user, so exported accounts retain portable media URLs and can move between Synapsis nodes without requiring the old node to transfer a shared upload directory. The node database is a local embedded Turso/SQLite file. Media remains in storage controlled by each user, so exported accounts retain portable media URLs and can move between Synapsis nodes without requiring the old node to transfer a shared upload directory.
Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Each Synapsis install registers its callback automatically the first time a user connects, so node operators do not need to create or configure a Stuffbox client ID. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox. Stuffbox is the exclusive media-storage integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Each Synapsis install registers its callback automatically the first time a user connects, so node operators do not need to create or configure a Stuffbox client ID. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox.
User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
## Encrypted direct messages ## Encrypted direct messages
@@ -99,7 +97,7 @@ npm run build
- **Framework:** Next.js 16 and React 19 - **Framework:** Next.js 16 and React 19
- **Database:** embedded Turso with Drizzle ORM's relational-query v2 API - **Database:** embedded Turso with Drizzle ORM's relational-query v2 API
- **Identity:** DIDs and per-user signing keys - **Identity:** DIDs and per-user signing keys
- **Media:** user-owned Stuffbox or S3-compatible storage - **Media:** user-owned Stuffbox storage
- **Federation:** Synapsis Swarm discovery and signed interactions - **Federation:** Synapsis Swarm discovery and signed interactions
- **Deployment:** native Node.js process managed by systemd - **Deployment:** native Node.js process managed by systemd
+60 -1667
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -21,7 +21,6 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.972.0",
"@tursodatabase/database": "0.7.0", "@tursodatabase/database": "0.7.0",
"@upstash/redis": "^1.34.3", "@upstash/redis": "^1.34.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
-61
View File
@@ -3,11 +3,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, 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 { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; 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 { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state'; import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
@@ -35,12 +33,8 @@ export default function AdminPage() {
const [isUploadingBanner, setIsUploadingBanner] = useState(false); const [isUploadingBanner, setIsUploadingBanner] = useState(false);
const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false); const [isCheckingBannerStorage, setIsCheckingBannerStorage] = useState(false);
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null); const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
const [showBannerSessionPrompt, setShowBannerSessionPrompt] = useState(false);
const [showBannerStorageConfiguration, setShowBannerStorageConfiguration] = 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 [bannerPromptError, setBannerPromptError] = useState('');
const [isRefreshingBannerSession, setIsRefreshingBannerSession] = useState(false);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [logoUploadError, setLogoUploadError] = useState<string | null>(null); const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
@@ -127,21 +121,12 @@ export default function AdminPage() {
setNodeSettings(nextSettings); setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings); await handleSaveSettings(nextSettings);
setPendingBannerFile(null); setPendingBannerFile(null);
setShowBannerSessionPrompt(false);
setBannerPassword('');
setBannerPromptError('');
} catch (error) { } catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) { if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingBannerFile(file); setPendingBannerFile(file);
setShowBannerStorageConfiguration(true); setShowBannerStorageConfiguration(true);
return; return;
} }
if (error instanceof MediaUploadError && error.status === 401 && allowPrompt) {
setPendingBannerFile(file);
setBannerPromptError('');
setShowBannerSessionPrompt(true);
return;
}
console.error('Banner upload failed', error); console.error('Banner upload failed', error);
setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.'); setBannerUploadError(error instanceof Error ? error.message : 'Upload failed. Please try again.');
} finally { } finally {
@@ -174,39 +159,6 @@ export default function AdminPage() {
} }
}; };
const handleBannerSessionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pendingBannerFile) {
setShowBannerSessionPrompt(false);
return;
}
if (!bannerPassword.trim()) {
setBannerPromptError('Please enter your password');
return;
}
setIsRefreshingBannerSession(true);
setBannerPromptError('');
try {
await refreshStorageSession(bannerPassword.trim());
await uploadBannerFile(pendingBannerFile, false);
} catch (error) {
setBannerPromptError(error instanceof Error ? error.message : 'Failed to confirm password');
} finally {
setIsRefreshingBannerSession(false);
}
};
const handleBannerSessionCancel = () => {
setShowBannerSessionPrompt(false);
setPendingBannerFile(null);
setBannerPassword('');
setBannerPromptError('');
};
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ''; event.target.value = '';
@@ -621,19 +573,6 @@ export default function AdminPage() {
</div> </div>
)} )}
<StorageSessionPrompt
open={showBannerSessionPrompt}
isSubmitting={isRefreshingBannerSession}
password={bannerPassword}
error={bannerPromptError}
description="Please confirm your password to continue uploading this banner to your storage."
onPasswordChange={(nextPassword) => {
setBannerPassword(nextPassword);
setBannerPromptError('');
}}
onSubmit={handleBannerSessionSubmit}
onCancel={handleBannerSessionCancel}
/>
<StorageConfigurationPrompt <StorageConfigurationPrompt
open={showBannerStorageConfiguration} open={showBannerStorageConfiguration}
onConfigured={async () => { onConfigured={async () => {
-2
View File
@@ -1,6 +1,5 @@
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';
@@ -31,7 +30,6 @@ 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,
-5
View File
@@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { destroySession, getSession } from '@/lib/auth'; import { destroySession, getSession } from '@/lib/auth';
import { clearStorageSession } from '@/lib/storage/session';
import { z } from 'zod'; import { z } from 'zod';
const logoutSchema = z.object({ const logoutSchema = z.object({
@@ -15,10 +14,6 @@ export async function POST(request: Request) {
const targetUserId = data.userId ?? currentSession?.user.id; const targetUserId = data.userId ?? currentSession?.user.id;
if (targetUserId) {
await clearStorageSession(targetUserId);
}
await destroySession(targetUserId); await destroySession(targetUserId);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
+1 -30
View File
@@ -17,8 +17,6 @@ import {
BotHandleTakenError, BotHandleTakenError,
BotValidationError, BotValidationError,
} from '@/lib/bots/botManager'; } from '@/lib/bots/botManager';
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({
@@ -45,7 +43,6 @@ const createBotSchema = z.object({
timezone: z.string().optional(), timezone: z.string().optional(),
}).optional(), }).optional(),
autonomousMode: z.boolean().optional(), autonomousMode: z.boolean().optional(),
ownerPassword: z.string().optional(),
}); });
/** /**
@@ -62,37 +59,11 @@ 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);
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;
if (!botAvatarUrl && storageSession) {
try {
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const botHandle = `${data.handle.toLowerCase()}@${nodeDomain}`;
botAvatarUrl = await generateAndUploadAvatarToUserStorage(
botHandle,
storageSession.endpoint || undefined,
storageSession.publicBaseUrl || undefined,
storageSession.region || 'us-east-1',
storageSession.bucket,
storageSession.accessKeyId,
storageSession.secretAccessKey
);
} catch (err) {
console.error('[Bot API] Failed to generate bot avatar:', err);
// Continue without avatar - user can set it later
}
}
const bot = await createBot(user.id, { const bot = await createBot(user.id, {
name: data.name, name: data.name,
handle: data.handle, handle: data.handle,
bio: data.bio, bio: data.bio,
avatarUrl: botAvatarUrl ?? undefined, avatarUrl: data.avatarUrl,
headerUrl: data.headerUrl, headerUrl: data.headerUrl,
personality: data.personality, personality: data.personality,
llmProvider: data.llmProvider, llmProvider: data.llmProvider,
-115
View File
@@ -1,115 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { getStorageSession, createStorageSession } from '@/lib/storage/session';
import { uploadWithStorageCredentials } from '@/lib/storage/s3';
import { v4 as uuidv4 } from 'uuid';
import { getMaxMediaSize, getMediaKind } from '@/lib/media/upload-policy';
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const formData = await req.formData();
const file = formData.get('file') as File | null;
const altText = (formData.get('alt') as string | null) || null;
const password = formData.get('password') as string | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
const mediaKind = getMediaKind(file.type);
if (mediaKind === 'unsupported') {
return NextResponse.json({
error: 'Invalid file type. Upload an image, video, MP3, M4A, AAC, WAV, OGG, or FLAC file.'
}, { status: 400 });
}
// Validate file size based on type
const maxSize = getMaxMediaSize(file.type);
if (maxSize !== null && file.size > maxSize) {
return NextResponse.json({
error: `File too large. Maximum size: ${mediaKind === 'image' ? '10MB' : '100MB'}`
}, { status: 400 });
}
// Check if user has S3 storage configured
if (!user.storageProvider || !user.storageAccessKeyEncrypted || !user.storageSecretKeyEncrypted) {
return NextResponse.json({
error: 'Connect your storage before uploading media.',
code: 'STORAGE_NOT_CONFIGURED',
}, { status: 409 });
}
const storageSession =
(await getStorageSession(user.id)) ||
(password ? await createStorageSession(user, password) : null);
if (!storageSession) {
return NextResponse.json({
error: 'Upload session expired. Please sign in again.'
}, { status: 401 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${uuidv4()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '')}`;
// Upload to user's own S3-compatible storage
const uploadResult = await uploadWithStorageCredentials(
buffer,
filename,
file.type,
storageSession.provider,
storageSession.endpoint,
storageSession.publicBaseUrl,
storageSession.region,
storageSession.bucket,
storageSession.accessKeyId,
storageSession.secretAccessKey
);
// Store media record with S3 URL
if (db) {
const [mediaRecord] = await db.insert(media).values({
userId: user.id,
postId: null,
url: uploadResult.url,
altText,
mimeType: file.type,
width: 0, // TODO: Get actual dimensions
height: 0,
}).returning();
return NextResponse.json({
success: true,
media: mediaRecord,
url: uploadResult.url,
key: uploadResult.key,
});
}
return NextResponse.json({
success: true,
url: uploadResult.url,
key: uploadResult.key,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'Invalid storage password') {
return NextResponse.json({
error: 'Incorrect password. Please try again.'
}, { status: 401 });
}
if (error instanceof Error && error.message.includes('Storage')) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error('Upload error:', error);
return NextResponse.json({ error: 'Upload failed' }, { status: 500 });
}
}
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
requireAuth: vi.fn(),
configuredStuffboxUrl: vi.fn(),
getStuffboxConnection: vi.fn(),
}));
vi.mock('@/lib/auth', () => ({ requireAuth: mocks.requireAuth }));
vi.mock('@/lib/stuffbox/client', () => ({ configuredStuffboxUrl: mocks.configuredStuffboxUrl }));
vi.mock('@/lib/stuffbox/tokens', () => ({ getStuffboxConnection: mocks.getStuffboxConnection }));
import { GET } from './route';
describe('GET /api/storage/configuration', () => {
beforeEach(() => {
mocks.requireAuth.mockReset();
mocks.configuredStuffboxUrl.mockReset();
mocks.getStuffboxConnection.mockReset();
mocks.configuredStuffboxUrl.mockReturnValue('https://stuffbox.example');
mocks.getStuffboxConnection.mockResolvedValue(null);
});
it('does not expose legacy S3 credentials as an active storage provider', async () => {
mocks.requireAuth.mockResolvedValue({
id: 'user-1',
storageProvider: 's3',
storageBucket: 'legacy-bucket',
});
const response = await GET();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
provider: null,
stuffboxAvailable: true,
stuffboxBaseUrl: null,
stuffboxUpdatedAt: null,
});
});
});
+2 -74
View File
@@ -1,35 +1,17 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { eq } from 'drizzle-orm'; import { requireAuth } from '@/lib/auth';
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';
import { configuredStuffboxUrl } from '@/lib/stuffbox/client'; import { configuredStuffboxUrl } from '@/lib/stuffbox/client';
import { getStuffboxConnection } from '@/lib/stuffbox/tokens'; import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
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 GET() { export async function GET() {
try { try {
const user = await requireAuth(); const user = await requireAuth();
const stuffbox = await getStuffboxConnection(user.id); const stuffbox = await getStuffboxConnection(user.id);
return NextResponse.json({ return NextResponse.json({
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null, provider: stuffbox ? 'stuffbox' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()), stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null, stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null, stuffboxUpdatedAt: stuffbox?.updatedAt.toISOString() ?? null,
s3Provider: user.storageProvider ?? null,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'Authentication required') { if (error instanceof Error && error.message === 'Authentication required') {
@@ -39,57 +21,3 @@ export async function GET() {
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 }); return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
} }
} }
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 });
}
}
-44
View File
@@ -1,44 +0,0 @@
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 });
}
+1 -1
View File
@@ -988,7 +988,7 @@ export default function ChatPage() {
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: '11px', color: 'var(--foreground-secondary)' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, fontSize: '11px', color: 'var(--foreground-secondary)' }}>
<LockKeyhole size={11} aria-hidden="true" /> <LockKeyhole size={11} aria-hidden="true" />
<span>{selectedEncryptionReady <span>{selectedEncryptionReady
? 'End-to-end encryption ready' ? 'End-to-end encrypted messaging'
: selectedEncryptionError : selectedEncryptionError
? 'Encryption unavailable' ? 'Encryption unavailable'
: 'Verifying encryption…'}</span> : 'Verifying encryption…'}</span>
+1 -1
View File
@@ -44,7 +44,7 @@ export default function SettingsPage() {
Media Storage Media Storage
</div> </div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}> <div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Connect Stuffbox or an S3-compatible bucket Connect and manage Stuffbox
</div> </div>
</Link> </Link>
+6 -8
View File
@@ -2,15 +2,14 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Box, Cloud, ExternalLink, HardDrive } from 'lucide-react'; import { Box, ExternalLink } from 'lucide-react';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
interface StorageStatus { interface StorageStatus {
provider: 'stuffbox' | 's3' | null; provider: 'stuffbox' | null;
stuffboxAvailable: boolean; stuffboxAvailable: boolean;
stuffboxBaseUrl: string | null; stuffboxBaseUrl: string | null;
s3Provider: string | null;
} }
export default function StorageSettingsPage() { export default function StorageSettingsPage() {
@@ -68,10 +67,9 @@ export default function StorageSettingsPage() {
<div className="card" style={{ padding: '20px', marginBottom: '16px' }}> <div className="card" style={{ padding: '20px', marginBottom: '16px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}> <div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />} <Box size={22} />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : status.provider === 's3' ? 'S3 storage connected' : 'No media storage connected'}</div> <div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox.xyz connected' : 'Stuffbox not connected'}</div>
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
</div> </div>
</div> </div>
{status?.provider === 'stuffbox' && ( {status?.provider === 'stuffbox' && (
@@ -89,10 +87,10 @@ export default function StorageSettingsPage() {
{status?.provider !== 'stuffbox' && ( {status?.provider !== 'stuffbox' && (
<div className="card" style={{ padding: '20px' }}> <div className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}> <h2 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
{status?.provider ? 'Change storage' : 'Connect media storage'} Connect Stuffbox
</h2> </h2>
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}> <p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.5, marginBottom: '20px' }}>
Choose Stuffbox for the simplest setup, or connect an S3-compatible bucket you already own. Connect the official Synapsis storage partner to upload portable media from your account.
</p> </p>
<StorageConfigurationPrompt open onConfigured={loadStatus} onCancel={() => {}} variant="inline" /> <StorageConfigurationPrompt open onConfigured={loadStatus} onCancel={() => {}} variant="inline" />
</div> </div>
+5 -74
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Box, ChevronDown, ExternalLink } from 'lucide-react'; import { Box, ExternalLink } from 'lucide-react';
import { hasNewStuffboxConnection } from '@/lib/stuffbox/browser-upload'; import { hasNewStuffboxConnection } from '@/lib/stuffbox/browser-upload';
import { import {
monitorStuffboxConnection, monitorStuffboxConnection,
@@ -17,19 +17,9 @@ interface StorageConfigurationPromptProps {
} }
export function StorageConfigurationPrompt({ open, onConfigured, onCancel, variant = 'modal' }: StorageConfigurationPromptProps) { export function StorageConfigurationPrompt({ open, onConfigured, onCancel, variant = 'modal' }: 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 [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [stuffboxAvailable, setStuffboxAvailable] = useState(false); const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
const [showS3, setShowS3] = useState(false);
const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false); const [isConnectingStuffbox, setIsConnectingStuffbox] = useState(false);
const connectionAbortRef = useRef<AbortController | null>(null); const connectionAbortRef = useRef<AbortController | null>(null);
const connectionPopupRef = useRef<Window | null>(null); const connectionPopupRef = useRef<Window | null>(null);
@@ -76,8 +66,6 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
}, [open]); }, [open]);
if (!open) return null; if (!open) return null;
const needsCustomUrls = provider === 'r2' || provider === 'b2' || provider === 'contabo';
const connectStuffbox = async () => { const connectStuffbox = async () => {
setError(''); setError('');
setIsConnectingStuffbox(true); setIsConnectingStuffbox(true);
@@ -149,33 +137,13 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
} }
}; };
const connectS3 = 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);
}
};
const content = ( const content = (
<> <>
{variant === 'modal' && ( {variant === 'modal' && (
<> <>
<h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect media storage</h3> <h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect Stuffbox</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}> <p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
Your media lives outside this Synapsis node, so your account stays portable. Synapsis uses Stuffbox for portable, user-owned media storage.
</p> </p>
</> </>
)} )}
@@ -190,7 +158,7 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
</div> </div>
</div> </div>
</div> </div>
<button type="button" className="btn btn-primary" onClick={isConnectingStuffbox ? cancelStuffboxConnection : connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}> <button type="button" className="btn btn-primary" onClick={isConnectingStuffbox ? cancelStuffboxConnection : connectStuffbox} disabled={isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
{isConnectingStuffbox ? 'Cancel connection' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'} {isConnectingStuffbox ? 'Cancel connection' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
</button> </button>
{isConnectingStuffbox && ( {isConnectingStuffbox && (
@@ -200,39 +168,9 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
)} )}
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} disabled={isConnectingStuffbox} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
</button>
{showS3 && (
<form onSubmit={connectS3} style={{ marginTop: '16px' }}>
<p style={{ color: 'var(--foreground-tertiary)', fontSize: '13px', lineHeight: 1.45, marginBottom: '14px' }}>
Advanced option. Credentials are encrypted locally; you may need to confirm your password again after the node restarts.
</p>
<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' }}>
<Field label="S3 endpoint" value={endpoint} onChange={setEndpoint} placeholder="https://…" />
<Field label="Public media URL" value={publicBaseUrl} onChange={setPublicBaseUrl} placeholder="https://…" />
</div>}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<Field label="Region" value={region} onChange={setRegion} />
<Field label="Bucket" value={bucket} onChange={setBucket} />
</div>
<Field label="Access key ID" value={accessKey} onChange={setAccessKey} type="password" minLength={10} />
<Field label="Secret access key" value={secretKey} onChange={setSecretKey} type="password" minLength={10} />
<Field label="Synapsis account password" value={password} onChange={setPassword} type="password" />
<button type="submit" className="btn btn-primary" disabled={isSubmitting} style={{ width: '100%' }}>{isSubmitting ? 'Connecting…' : 'Connect S3 storage'}</button>
</form>
)}
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>} {error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
{variant === 'modal' && ( {variant === 'modal' && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={() => { cancelStuffboxConnection(); onCancel(); }} disabled={isSubmitting}>Cancel</button></div> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={() => { cancelStuffboxConnection(); onCancel(); }}>Cancel</button></div>
)} )}
</> </>
); );
@@ -247,10 +185,3 @@ export function StorageConfigurationPrompt({ open, onConfigured, onCancel, varia
</div> </div>
); );
} }
function Field({ label, value, onChange, type = 'text', placeholder, minLength }: { label: string; value: string; onChange: (value: string) => void; type?: string; placeholder?: string; minLength?: number }) {
return <label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>{label}</span>
<input className="input" type={type} value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} required minLength={minLength} />
</label>;
}
-97
View File
@@ -1,97 +0,0 @@
'use client';
interface StorageSessionPromptProps {
open: boolean;
isSubmitting: boolean;
password: string;
error: string;
title?: string;
description?: string;
onPasswordChange: (password: string) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onCancel: () => void;
}
export function StorageSessionPrompt({
open,
isSubmitting,
password,
error,
title = 'Confirm your password',
description = 'Please confirm your password to continue uploading to your storage.',
onPasswordChange,
onSubmit,
onCancel,
}: StorageSessionPromptProps) {
if (!open) {
return null;
}
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 99999,
padding: '20px',
}}
onClick={onCancel}
>
<div
className="card"
style={{ width: '100%', maxWidth: '420px', padding: '20px' }}
onClick={(event) => event.stopPropagation()}
>
<h3 style={{ fontSize: '18px', fontWeight: 600, marginBottom: '8px' }}>
{title}
</h3>
<p style={{ color: 'var(--foreground-secondary)', marginBottom: '16px', lineHeight: 1.5 }}>
{description}
</p>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: '12px' }}>
<label
htmlFor="storage-session-password"
style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: 500,
}}
>
Password
</label>
<input
id="storage-session-password"
type="password"
className="input"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
autoFocus
/>
</div>
{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 ? 'Confirming...' : 'Continue'}
</button>
</div>
</form>
</div>
</div>
);
}
-61
View File
@@ -1,9 +1,7 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { refreshStorageSession } from '@/lib/storage/client';
import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
interface UserStorageImageUploadProps { interface UserStorageImageUploadProps {
@@ -31,12 +29,8 @@ export function UserStorageImageUpload({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isCheckingStorage, setIsCheckingStorage] = useState(false); const [isCheckingStorage, setIsCheckingStorage] = useState(false);
const [storageNotice, setStorageNotice] = useState(''); const [storageNotice, setStorageNotice] = useState('');
const [showSessionPrompt, setShowSessionPrompt] = useState(false);
const [showConfigurationPrompt, setShowConfigurationPrompt] = 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 [promptError, setPromptError] = useState('');
const [isRefreshingSession, setIsRefreshingSession] = useState(false);
const resetFileInput = () => { const resetFileInput = () => {
if (inputRef.current) { if (inputRef.current) {
@@ -53,21 +47,12 @@ export function UserStorageImageUpload({
onChange(media.url); onChange(media.url);
onError?.(''); onError?.('');
setPendingFile(null); setPendingFile(null);
setShowSessionPrompt(false);
setPassword('');
setPromptError('');
} catch (error) { } catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) { if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingFile(file); setPendingFile(file);
setShowConfigurationPrompt(true); setShowConfigurationPrompt(true);
return; return;
} }
if (error instanceof MediaUploadError && error.status === 401 && allowPrompt) {
setPendingFile(file);
setPromptError('');
setShowSessionPrompt(true);
return;
}
onError?.(error instanceof Error ? error.message : 'Upload failed'); onError?.(error instanceof Error ? error.message : 'Upload failed');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
@@ -99,40 +84,6 @@ export function UserStorageImageUpload({
} }
}; };
const handlePromptSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!pendingFile) {
setShowSessionPrompt(false);
return;
}
if (!password.trim()) {
setPromptError('Please enter your password');
return;
}
setIsRefreshingSession(true);
setPromptError('');
try {
await refreshStorageSession(password.trim());
await uploadFile(pendingFile, false);
} catch (error) {
setPromptError(error instanceof Error ? error.message : 'Failed to confirm password');
} finally {
setIsRefreshingSession(false);
}
};
const handlePromptCancel = () => {
setShowSessionPrompt(false);
setPendingFile(null);
setPassword('');
setPromptError('');
resetFileInput();
};
return ( return (
<> <>
<div> <div>
@@ -195,18 +146,6 @@ export function UserStorageImageUpload({
)} )}
</div> </div>
<StorageSessionPrompt
open={showSessionPrompt}
isSubmitting={isRefreshingSession}
password={password}
error={promptError}
onPasswordChange={(nextPassword) => {
setPassword(nextPassword);
setPromptError('');
}}
onSubmit={handlePromptSubmit}
onCancel={handlePromptCancel}
/>
<StorageConfigurationPrompt <StorageConfigurationPrompt
open={showConfigurationPrompt} open={showConfigurationPrompt}
onConfigured={async () => { onConfigured={async () => {
+9 -8
View File
@@ -67,14 +67,15 @@ 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
// Optional user-owned S3-compatible storage, configured on first media upload // Legacy S3 fields retained so upgrades do not require a destructive table rebuild.
storageProvider: text('storage_provider'), // 's3', 'r2', 'b2', 'wasabi', 'contabo' // New media storage connections use Stuffbox exclusively.
storageEndpoint: text('storage_endpoint'), // S3 endpoint URL (optional for AWS) storageProvider: text('storage_provider'),
storagePublicBaseUrl: text('storage_public_base_url'), // Public URL for viewing files (required for R2, B2, Contabo) storageEndpoint: text('storage_endpoint'),
storageRegion: text('storage_region'), // Region (e.g., 'us-east-1') storagePublicBaseUrl: text('storage_public_base_url'),
storageBucket: text('storage_bucket'), // Bucket name storageRegion: text('storage_region'),
storageAccessKeyEncrypted: text('storage_access_key_encrypted'), // Encrypted access key storageBucket: text('storage_bucket'),
storageSecretKeyEncrypted: text('storage_secret_key_encrypted'), // Encrypted secret key storageAccessKeyEncrypted: text('storage_access_key_encrypted'),
storageSecretKeyEncrypted: text('storage_secret_key_encrypted'),
followersCount: integer('followers_count').default(0).notNull(), followersCount: integer('followers_count').default(0).notNull(),
followingCount: integer('following_count').default(0).notNull(), followingCount: integer('following_count').default(0).notNull(),
postsCount: integer('posts_count').default(0).notNull(), postsCount: integer('posts_count').default(0).notNull(),
+2 -15
View File
@@ -11,7 +11,6 @@
*/ */
import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db'; import { db, bots, users, botContentSources, botContentItems, botMentions, botActivityLogs, botRateLimits, follows } from '@/db';
import { generateAndUploadAvatarToUserStorage } from '@/lib/storage/s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key'; import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
import { eq, and, count } from 'drizzle-orm'; import { eq, and, count } from 'drizzle-orm';
import { generateKeyPair } from '@/lib/crypto/keys'; import { generateKeyPair } from '@/lib/crypto/keys';
@@ -403,7 +402,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
throw new BotHandleTakenError(config.handle); throw new BotHandleTakenError(config.handle);
} }
// Get owner to access their S3 storage for bot avatar // Confirm the owner still exists before creating the bot user.
const owner = await db.query.users.findFirst({ const owner = await db.query.users.findFirst({
where: { id: ownerId }, where: { id: ownerId },
}); });
@@ -423,19 +422,7 @@ export async function createBot(ownerId: string, config: BotCreateInput): Promis
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821';
const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`; const botDid = `did:web:${nodeDomain}:users:${config.handle.toLowerCase()}`;
// Generate bot avatar using owner's S3 storage if no avatar provided const botAvatarUrl = config.avatarUrl || null;
let botAvatarUrl = config.avatarUrl || null;
if (!botAvatarUrl && owner.storageAccessKeyEncrypted && owner.storageSecretKeyEncrypted && owner.storageBucket) {
try {
// We need the owner's password to decrypt S3 credentials
// Since we don't have the password here, we'll leave avatar null
// The frontend should handle avatar generation with the user's password
// Or we can generate a default avatar URL pattern
console.log('[BotManager] Bot avatar will need to be set via API with user password');
} catch (err) {
console.error('[BotManager] Failed to generate bot avatar:', err);
}
}
// Create the bot's user account first // Create the bot's user account first
const [botUser] = await db.insert(users).values({ const [botUser] = await db.insert(users).values({
-13
View File
@@ -1,13 +0,0 @@
export async function refreshStorageSession(password: string): Promise<void> {
const res = await fetch('/api/storage/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to confirm password');
}
}
-246
View File
@@ -1,246 +0,0 @@
/**
* User-Owned S3-Compatible Storage Utilities
*
* Supports AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo.
*/
import { S3Client, PutObjectCommand, HeadBucketCommand } from '@aws-sdk/client-s3';
import { decryptPrivateKey, deserializeEncryptedKey } from '@/lib/crypto/private-key';
export type StorageProvider = 's3' | 'r2' | 'b2' | 'wasabi' | 'contabo';
interface S3Credentials {
endpoint?: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
bucket: string;
}
interface StorageUploadResult {
url: 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
*/
export function decryptS3Credentials(
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): { accessKeyId: string; secretAccessKey: string } {
try {
const accessKeyId = decryptPrivateKey(
deserializeEncryptedKey(encryptedAccessKey),
password
);
const secretAccessKey = decryptPrivateKey(
deserializeEncryptedKey(encryptedSecretKey),
password
);
return { accessKeyId, secretAccessKey };
} catch {
throw new Error('Invalid storage password');
}
}
/**
* Create S3 client from credentials
*/
function createS3Client(creds: S3Credentials): S3Client {
return new S3Client({
region: creds.region,
endpoint: creds.endpoint,
credentials: {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
},
forcePathStyle: !!creds.endpoint, // Needed for non-AWS S3-compatible services
});
}
/**
* Upload a file to user's S3-compatible storage
*/
export async function uploadToUserStorage(
file: Buffer,
filename: string,
mimeType: string,
provider: StorageProvider,
endpoint: string | null,
publicBaseUrl: string | null,
region: string,
bucket: string,
encryptedAccessKey: string,
encryptedSecretKey: string,
password: string
): Promise<StorageUploadResult> {
const { accessKeyId, secretAccessKey } = decryptS3Credentials(
encryptedAccessKey,
encryptedSecretKey,
password
);
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({
endpoint: endpoint || undefined,
region: normalizedRegion,
accessKeyId,
secretAccessKey,
bucket,
});
const key = `synapsis/${filename}`;
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: file,
ContentType: mimeType,
}));
const url = buildStorageUrl(key, endpoint, publicBaseUrl, normalizedRegion, bucket);
return { url, key };
}
/**
* Test S3 credentials by attempting to head the bucket
*/
export async function testS3Credentials(
endpoint: string | null,
region: string,
bucket: string,
accessKeyId: string,
secretAccessKey: string
): Promise<{ success: boolean; error?: string }> {
try {
const s3 = createS3Client({
endpoint: endpoint || undefined,
region,
accessKeyId,
secretAccessKey,
bucket,
});
// Try to check if bucket exists/is accessible
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
return { success: true };
} catch (error: any) {
console.error('[S3 Test] Credential test failed:', error);
// Parse common errors
if (error.name === 'Forbidden' || error.name === '403') {
return { success: false, error: 'Access denied. Check your Access Key and Secret Key.' };
}
if (error.name === 'NotFound' || error.name === '404') {
return { success: false, error: `Bucket "${bucket}" not found. Check the bucket name.` };
}
if (error.name === 'NoSuchBucket') {
return { success: false, error: `Bucket "${bucket}" does not exist.` };
}
if (error.name === 'InvalidAccessKeyId') {
return { success: false, error: 'Invalid Access Key ID.' };
}
if (error.name === 'SignatureDoesNotMatch') {
return { success: false, error: 'Invalid Secret Access Key.' };
}
if (error.name === 'NetworkingError' || error.name === 'ECONNREFUSED') {
return { success: false, error: 'Cannot connect to endpoint. Check your endpoint URL.' };
}
return { success: false, error: error.message || 'Failed to connect to storage. Please check your credentials.' };
}
}
/**
* Generate and upload avatar to user's S3 storage
*/
export async function generateAndUploadAvatarToUserStorage(
handle: string,
endpoint: string | undefined,
publicBaseUrl: string | undefined,
region: string,
bucket: string,
accessKey: string,
secretKey: string
): Promise<string | null> {
try {
// 1. Fetch the avatar from DiceBear (PNG format for better compatibility)
const dicebearUrl = `https://api.dicebear.com/9.x/bottts-neutral/png?seed=${handle}`;
const response = await fetch(dicebearUrl);
if (!response.ok) {
console.error(`Failed to fetch avatar from DiceBear: ${response.statusText}`);
return null;
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const result = await uploadWithStorageCredentials(
buffer,
`avatars/${handle.replace(/[^a-zA-Z0-9]/g, '')}-avatar.png`,
'image/png',
's3',
endpoint || null,
publicBaseUrl || null,
region,
bucket,
accessKey,
secretKey
);
return result.url;
} catch (error) {
console.error('Error generating/uploading avatar:', error);
return null;
}
}
-187
View File
@@ -1,187 +0,0 @@
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_sessions';
const STORAGE_SESSION_TTL_MS = 3650 * 24 * 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;
}
type StorageSessionMap = Record<string, StorageSessionPayload>;
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;
}
function encryptPayloadMap(payload: StorageSessionMap): string {
return encryptPayload(payload as unknown as StorageSessionPayload);
}
function decryptPayloadMap(token: string): StorageSessionMap {
return decryptPayload(token) as unknown as StorageSessionMap;
}
async function readStorageSessionMap(): Promise<StorageSessionMap> {
const cookieStore = await cookies();
const token = cookieStore.get(STORAGE_SESSION_COOKIE)?.value;
if (!token) {
return {};
}
try {
const payload = decryptPayloadMap(token);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
await clearStorageSession();
return {};
}
return payload;
} catch {
await clearStorageSession();
return {};
}
}
async function writeStorageSessionMap(payload: StorageSessionMap): Promise<void> {
const cookieStore = await cookies();
if (Object.keys(payload).length === 0) {
cookieStore.delete(STORAGE_SESSION_COOKIE);
return;
}
const maxExpiresAt = Math.max(...Object.values(payload).map(session => session.expiresAt));
cookieStore.set(STORAGE_SESSION_COOKIE, encryptPayloadMap(payload), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
expires: new Date(maxExpiresAt),
});
}
export async function createStorageSession(
user: typeof users.$inferSelect,
password: string
): Promise<StorageSessionPayload | null> {
if (
!user.storageProvider ||
!user.storageAccessKeyEncrypted ||
!user.storageSecretKeyEncrypted ||
!user.storageBucket
) {
await clearStorageSession(user.id);
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 existingSessions = await readStorageSessionMap();
existingSessions[user.id] = payload;
await writeStorageSessionMap(existingSessions);
return payload;
}
export async function getStorageSession(userId: string): Promise<StorageSessionPayload | null> {
const sessions = await readStorageSessionMap();
const payload = sessions[userId];
if (!payload) {
return null;
}
if (payload.expiresAt <= Date.now()) {
delete sessions[userId];
await writeStorageSessionMap(sessions);
return null;
}
return payload;
}
export async function clearStorageSession(userId?: string): Promise<void> {
if (!userId) {
const cookieStore = await cookies();
cookieStore.delete(STORAGE_SESSION_COOKIE);
return;
}
const sessions = await readStorageSessionMap();
if (!(userId in sessions)) {
return;
}
delete sessions[userId];
await writeStorageSessionMap(sessions);
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { hasNewStuffboxConnection } from './browser-upload'; import { getStorageProvider, hasNewStuffboxConnection } from './browser-upload';
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -23,3 +23,12 @@ describe('hasNewStuffboxConnection', () => {
}); });
}); });
describe('getStorageProvider', () => {
it('does not accept a legacy S3 provider', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
provider: 's3',
}), { status: 200 })));
await expect(getStorageProvider()).resolves.toBeNull();
});
});
+3 -17
View File
@@ -29,7 +29,7 @@ interface JsonResponse extends Record<string, unknown> {
stuffboxUpdatedAt?: string | null; stuffboxUpdatedAt?: string | null;
} }
export type StorageProvider = 'stuffbox' | 's3' | null; export type StorageProvider = 'stuffbox' | null;
async function json(response: Response): Promise<JsonResponse> { async function json(response: Response): Promise<JsonResponse> {
return response.json().catch(() => ({})) as Promise<JsonResponse>; return response.json().catch(() => ({})) as Promise<JsonResponse>;
@@ -41,9 +41,7 @@ export async function getStorageProvider(): Promise<StorageProvider> {
if (!response.ok) { if (!response.ok) {
throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, response.status); throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, response.status);
} }
return configuration.provider === 'stuffbox' || configuration.provider === 's3' return configuration.provider === 'stuffbox' ? 'stuffbox' : null;
? configuration.provider
: null;
} }
export async function hasNewStuffboxConnection(startedAt: string): Promise<boolean> { export async function hasNewStuffboxConnection(startedAt: string): Promise<boolean> {
@@ -119,23 +117,11 @@ async function uploadToStuffbox(file: File, onProgress?: (progress: number) => v
return complete.media as UploadedMedia; return complete.media as UploadedMedia;
} }
async function uploadToS3(file: File): Promise<UploadedMedia> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/media/upload', { method: 'POST', body: formData });
const data = await json(response);
if (!response.ok || !data.media?.url) {
throw new MediaUploadError(data.error || 'Upload failed', data.code, response.status);
}
return data.media as UploadedMedia;
}
export async function uploadMediaFile( export async function uploadMediaFile(
file: File, file: File,
onProgress?: (progress: number) => void, onProgress?: (progress: number) => void,
): Promise<UploadedMedia> { ): Promise<UploadedMedia> {
const provider = await getStorageProvider(); const provider = await getStorageProvider();
if (provider === 'stuffbox') return uploadToStuffbox(file, onProgress); if (provider === 'stuffbox') return uploadToStuffbox(file, onProgress);
if (provider === 's3') return uploadToS3(file); throw new MediaUploadError('Connect Stuffbox before uploading.', 'STORAGE_NOT_CONFIGURED', 409);
throw new MediaUploadError('Connect media storage before uploading.', 'STORAGE_NOT_CONFIGURED', 409);
} }