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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+60
-1667
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user