Add Stuffbox account connection, encrypted token lifecycle, direct media uploads, storage settings, and S3 fallback

Hop-State: A_06FP7VQ2F5KZQAY57YEBZV8
Hop-Proposal: R_06FP7VPDD7MJH2Z0013D7SR
Hop-Task: T_06FP7QPNG91NT6WBGJD5MJG
Hop-Attempt: AT_06FP7QPNGAEEAEHP3Q7CNEG
This commit is contained in:
2026-07-14 21:00:27 -07:00
committed by Hop
parent 752298a405
commit 9f0bfb59ab
26 changed files with 7657 additions and 168 deletions
+3
View File
@@ -18,6 +18,9 @@ ADMIN_EMAILS=admin@example.com
NEXT_PUBLIC_NODE_DOMAIN=localhost:43821 NEXT_PUBLIC_NODE_DOMAIN=localhost:43821
NEXT_PUBLIC_APP_URL=http://localhost:43821 NEXT_PUBLIC_APP_URL=http://localhost:43821
# Recommended: Stuffbox instance offered as the default user-owned media provider
# STUFFBOX_URL=https://stuffbox.example.com
# Optional: node metadata shown before a node record exists in the database # Optional: node metadata shown before a node record exists in the database
# NEXT_PUBLIC_NODE_NAME=Synapsis Local # NEXT_PUBLIC_NODE_NAME=Synapsis Local
# NEXT_PUBLIC_NODE_DESCRIPTION=Local development node # NEXT_PUBLIC_NODE_DESCRIPTION=Local development node
+5 -3
View File
@@ -45,9 +45,11 @@ Uninstalling preserves the database and environment by default. Pass `--purge-da
## Storage and account portability ## Storage and account portability
The node database is a local embedded Turso/SQLite file. Media remains in each user's S3-compatible bucket so exported accounts can retain portable media URLs and 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.
Supported S3-compatible providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo. Synapsis stores each user's credentials encrypted with `AUTH_SECRET`. Stuffbox is the default integration. Set `STUFFBOX_URL` to the public URL of the Stuffbox service users should connect. 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.
## Development ## Development
@@ -77,7 +79,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 S3-compatible storage - **Media:** user-owned Stuffbox or S3-compatible 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
@@ -0,0 +1,15 @@
CREATE TABLE `stuffbox_connections` (
`user_id` text PRIMARY KEY,
`base_url` text NOT NULL,
`access_token_encrypted` text NOT NULL,
`access_token_expires_at` integer NOT NULL,
`refresh_token_encrypted` text NOT NULL,
`refresh_token_expires_at` integer,
`scopes` text NOT NULL,
`connected_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
CONSTRAINT `fk_stuffbox_connections_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
ALTER TABLE `media` ADD `storage_provider` text;--> statement-breakpoint
ALTER TABLE `media` ADD `storage_asset_id` text;
File diff suppressed because it is too large Load Diff
+14 -24
View File
@@ -8,6 +8,7 @@ import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPro
import { useToast } from '@/lib/contexts/ToastContext'; import { useToast } from '@/lib/contexts/ToastContext';
import { useAccentColor } from '@/lib/contexts/AccentColorContext'; import { useAccentColor } from '@/lib/contexts/AccentColorContext';
import { refreshStorageSession } from '@/lib/storage/client'; import { refreshStorageSession } from '@/lib/storage/client';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
export default function AdminPage() { export default function AdminPage() {
const { showToast } = useToast(); const { showToast } = useToast();
@@ -105,33 +106,11 @@ export default function AdminPage() {
setIsUploadingBanner(true); setIsUploadingBanner(true);
try { try {
const formData = new FormData(); const media = await uploadMediaFile(file);
formData.append('file', file);
const res = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok || !data.url) {
if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingBannerFile(file);
setShowBannerStorageConfiguration(true);
return;
}
if (res.status === 401 && allowPrompt) {
setPendingBannerFile(file);
setBannerPromptError('');
setShowBannerSessionPrompt(true);
return;
}
throw new Error(data.error || 'Upload failed');
}
const nextSettings = { const nextSettings = {
...nodeSettings, ...nodeSettings,
bannerUrl: data.media?.url || data.url, bannerUrl: media.url,
}; };
setNodeSettings(nextSettings); setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings); await handleSaveSettings(nextSettings);
@@ -140,6 +119,17 @@ export default function AdminPage() {
setBannerPassword(''); setBannerPassword('');
setBannerPromptError(''); setBannerPromptError('');
} catch (error) { } catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingBannerFile(file);
setShowBannerStorageConfiguration(true);
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 {
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { db, media } from '@/db';
import { requireAuth } from '@/lib/auth';
import { completeUpload, StuffboxApiError } from '@/lib/stuffbox/client';
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
const bodySchema = z.object({ alt: z.string().max(1500).nullable().optional() });
type Context = { params: Promise<{ uploadId: string }> };
export async function POST(request: Request, context: Context) {
try {
const user = await requireAuth();
const body = bodySchema.parse(await request.json().catch(() => ({})));
const { uploadId } = await context.params;
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
const asset = await completeUpload(baseUrl, accessToken, uploadId);
const [record] = await db.insert(media).values({
userId: user.id,
postId: null,
url: asset.url,
storageProvider: 'stuffbox',
storageAssetId: asset.id,
altText: body.alt ?? null,
mimeType: asset.mimeType,
width: 0,
height: 0,
}).returning();
return NextResponse.json({ success: true, media: record, asset });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid upload completion' }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
return NextResponse.json({ error: 'Stuffbox is disconnected', code: 'STORAGE_NOT_CONFIGURED' }, { status: 409 });
}
if (error instanceof StuffboxApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
}
console.error('Stuffbox upload completion error:', error);
return NextResponse.json({ error: 'Unable to complete upload' }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { requireAuth } from '@/lib/auth';
import { createUpload, StuffboxApiError } from '@/lib/stuffbox/client';
import { getStuffboxAccess } from '@/lib/stuffbox/tokens';
const uploadSchema = z.object({
filename: z.string().min(1).max(255),
mimeType: z.enum([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime',
]),
size: z.number().int().positive().max(100 * 1024 * 1024),
sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
}).superRefine((upload, context) => {
if (upload.mimeType.startsWith('image/') && upload.size > 10 * 1024 * 1024) {
context.addIssue({ code: 'too_big', maximum: 10 * 1024 * 1024, origin: 'number', inclusive: true, path: ['size'], message: 'Images must be 10MB or smaller' });
}
});
export async function POST(request: Request) {
try {
const user = await requireAuth();
const input = uploadSchema.parse(await request.json());
const { baseUrl, accessToken } = await getStuffboxAccess(user.id);
const upload = await createUpload(baseUrl, accessToken, input);
return NextResponse.json(upload, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: 'Invalid upload', details: error.issues }, { status: 400 });
}
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof Error && error.message === 'STUFFBOX_NOT_CONNECTED') {
return NextResponse.json({
error: 'Connect Stuffbox before uploading media.',
code: 'STORAGE_NOT_CONFIGURED',
}, { status: 409 });
}
if (error instanceof StuffboxApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
}
console.error('Stuffbox upload-session error:', error);
return NextResponse.json({ error: 'Unable to create upload session' }, { status: 500 });
}
}
@@ -6,6 +6,8 @@ import { requireAuth, verifyPassword } from '@/lib/auth';
import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key'; import { encryptPrivateKey, serializeEncryptedKey } from '@/lib/crypto/private-key';
import { testS3Credentials, type StorageProvider } from '@/lib/storage/s3'; import { testS3Credentials, type StorageProvider } from '@/lib/storage/s3';
import { createStorageSession } from '@/lib/storage/session'; import { createStorageSession } from '@/lib/storage/session';
import { configuredStuffboxUrl } from '@/lib/stuffbox/client';
import { getStuffboxConnection } from '@/lib/stuffbox/tokens';
const configurationSchema = z.object({ const configurationSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
@@ -18,6 +20,25 @@ const configurationSchema = z.object({
secretKey: z.string().min(10), secretKey: z.string().min(10),
}); });
export async function GET() {
try {
const user = await requireAuth();
const stuffbox = await getStuffboxConnection(user.id);
return NextResponse.json({
provider: stuffbox ? 'stuffbox' : user.storageProvider ? 's3' : null,
stuffboxAvailable: Boolean(configuredStuffboxUrl()),
stuffboxBaseUrl: stuffbox?.baseUrl ?? null,
s3Provider: user.storageProvider ?? null,
});
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Storage status error:', error);
return NextResponse.json({ error: 'Failed to load storage status' }, { status: 500 });
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const user = await requireAuth(); const user = await requireAuth();
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { exchangeAuthorizationCode, StuffboxApiError } from '@/lib/stuffbox/client';
import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
import { saveStuffboxTokens } from '@/lib/stuffbox/tokens';
function popupResponse(origin: string, success: boolean, message: string): NextResponse {
const safeJson = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
const payload = safeJson({ type: 'synapsis:stuffbox', success, message });
const targetOrigin = safeJson(origin);
const fallback = success ? '/settings/storage?stuffbox=connected' : '/settings/storage?stuffbox=error';
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Stuffbox</title></head><body>
<p>${success ? 'Stuffbox connected. You can close this window.' : 'Stuffbox could not be connected.'}</p>
<script>
if (window.opener) { window.opener.postMessage(${payload}, ${targetOrigin}); window.close(); }
else { window.location.replace(${safeJson(fallback)}); }
</script>
</body></html>`;
return new NextResponse(html, {
status: success ? 200 : 400,
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
});
}
export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin;
try {
const user = await requireAuth();
const pending = await consumeStuffboxConnectionState();
const code = request.nextUrl.searchParams.get('code');
const state = request.nextUrl.searchParams.get('state');
const denied = request.nextUrl.searchParams.get('error');
if (denied) return popupResponse(origin, false, 'Stuffbox access was not approved.');
if (!pending || pending.userId !== user.id || !code || state !== pending.state) {
return popupResponse(origin, false, 'The Stuffbox connection request is invalid or expired.');
}
const tokens = await exchangeAuthorizationCode(pending.baseUrl, {
code,
codeVerifier: pending.verifier,
redirectUri: pending.redirectUri,
});
await saveStuffboxTokens(user.id, pending.baseUrl, tokens);
return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.');
} catch (error) {
if (error instanceof StuffboxApiError) {
return popupResponse(origin, false, error.message);
}
console.error('Stuffbox callback error:', error);
return popupResponse(origin, false, 'Stuffbox could not be connected.');
}
}
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/db';
import { requireAuth } from '@/lib/auth';
import { configuredStuffboxUrl, createConnectionRequest, StuffboxApiError } from '@/lib/stuffbox/client';
import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state';
import { generatePkce } from '@/lib/stuffbox/crypto';
import { STUFFBOX_SCOPES } from '@/lib/stuffbox/types';
function nodeOrigin(request: NextRequest): string {
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (appUrl) return new URL(appUrl).origin;
const configured = process.env.NEXT_PUBLIC_NODE_DOMAIN?.trim();
if (!configured) return request.nextUrl.origin;
const protocol = /^(localhost|127\.0\.0\.1)(:|$)/.test(configured) ? 'http' : 'https';
return new URL(configured.includes('://') ? configured : `${protocol}://${configured}`).origin;
}
export async function POST(request: NextRequest) {
try {
const user = await requireAuth();
const baseUrl = configuredStuffboxUrl();
if (!baseUrl) {
return NextResponse.json({
error: 'Stuffbox is not configured on this node.',
code: 'STUFFBOX_UNAVAILABLE',
}, { status: 503 });
}
const origin = nodeOrigin(request);
const redirectUri = `${origin}/api/storage/stuffbox/callback`;
const node = user.nodeId
? await db.query.nodes.findFirst({ where: { id: user.nodeId } })
: null;
const pkce = generatePkce();
const connection = await createConnectionRequest(baseUrl, {
nodeName: node?.name || new URL(origin).host,
callbackUrl: redirectUri,
codeChallenge: pkce.challenge,
state: pkce.state,
scopes: STUFFBOX_SCOPES,
});
await saveStuffboxConnectionState({
userId: user.id,
baseUrl,
verifier: pkce.verifier,
state: pkce.state,
redirectUri,
expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000),
});
return NextResponse.json({ authorizationUrl: connection.authorizationUrl });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
if (error instanceof StuffboxApiError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status || 502 });
}
console.error('Stuffbox connection error:', error);
return NextResponse.json({ error: 'Unable to start Stuffbox connection' }, { status: 500 });
}
}
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth';
import { revokeToken } from '@/lib/stuffbox/client';
import {
getStuffboxConnection,
readStuffboxRefreshToken,
removeStuffboxConnection,
} from '@/lib/stuffbox/tokens';
export async function POST() {
try {
const user = await requireAuth();
const connection = await getStuffboxConnection(user.id);
if (connection) {
try {
await revokeToken(connection.baseUrl, readStuffboxRefreshToken(connection));
} catch (error) {
console.warn('Stuffbox token revocation failed; removing local connection:', error);
}
await removeStuffboxConnection(user.id);
}
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof Error && error.message === 'Authentication required') {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
console.error('Stuffbox disconnect error:', error);
return NextResponse.json({ error: 'Unable to disconnect Stuffbox' }, { status: 500 });
}
}
+18 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { Rocket, Shield, Bell, Eye, UserX } from 'lucide-react'; import { Rocket, Shield, Bell, Eye, UserX, HardDrive } from 'lucide-react';
export default function SettingsPage() { export default function SettingsPage() {
return ( return (
@@ -26,6 +26,23 @@ export default function SettingsPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Link href="/settings/storage" className="card" style={{
display: 'block',
padding: '20px',
textDecoration: 'none',
color: 'var(--foreground)',
transition: 'border-color 0.15s ease',
}}>
<div style={{ fontWeight: 600, marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<HardDrive size={18} />
Media Storage
</div>
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px' }}>
Connect Stuffbox or an S3-compatible bucket
</div>
</Link>
<Link href="/settings/content" className="card" style={{ <Link href="/settings/content" className="card" style={{
display: 'block', display: 'block',
padding: '20px', padding: '20px',
+81
View File
@@ -0,0 +1,81 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Box, Cloud, HardDrive } from 'lucide-react';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
interface StorageStatus {
provider: 'stuffbox' | 's3' | null;
stuffboxAvailable: boolean;
stuffboxBaseUrl: string | null;
s3Provider: string | null;
}
export default function StorageSettingsPage() {
const [status, setStatus] = useState<StorageStatus | null>(null);
const [error, setError] = useState('');
const [showConnect, setShowConnect] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const loadStatus = useCallback(async () => {
try {
const response = await fetch('/api/storage/configuration', { cache: 'no-store' });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Unable to load storage settings');
setStatus(data);
setError('');
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Unable to load storage settings');
}
}, []);
useEffect(() => { void loadStatus(); }, [loadStatus]);
const disconnectStuffbox = async () => {
if (!window.confirm('Disconnect Stuffbox from this Synapsis account? Existing media links will keep working.')) return;
setIsDisconnecting(true);
setError('');
try {
const response = await fetch('/api/storage/stuffbox/disconnect', { method: 'POST' });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Unable to disconnect Stuffbox');
await loadStatus();
} catch (disconnectError) {
setError(disconnectError instanceof Error ? disconnectError.message : 'Unable to disconnect Stuffbox');
} finally {
setIsDisconnecting(false);
}
};
return <>
<header style={{ padding: '16px', borderBottom: '1px solid var(--border)', position: 'sticky', top: 0, background: 'var(--background)', zIndex: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Link href="/settings" className="btn btn-ghost btn-sm" aria-label="Back to settings"><ArrowLeft size={18} /></Link>
<h1 style={{ fontSize: '18px', fontWeight: 600 }}>Media Storage</h1>
</div>
</header>
<main style={{ maxWidth: '600px', margin: '0 auto', padding: '24px 16px 64px' }}>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
Storage belongs to your account rather than this node. That keeps your media available if you move your account elsewhere.
</p>
<div className="card" style={{ padding: '20px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
{status?.provider === 'stuffbox' ? <Box size={22} /> : status?.provider === 's3' ? <Cloud size={22} /> : <HardDrive size={22} />}
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{!status ? 'Loading…' : status.provider === 'stuffbox' ? 'Stuffbox connected' : status.provider === 's3' ? 'S3 storage connected' : 'No media storage connected'}</div>
{status?.stuffboxBaseUrl && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px', wordBreak: 'break-all' }}>{status.stuffboxBaseUrl}</div>}
{status?.s3Provider && status.provider === 's3' && <div style={{ color: 'var(--foreground-secondary)', fontSize: '13px', marginTop: '5px' }}>Provider: {status.s3Provider}</div>}
</div>
</div>
<div style={{ display: 'flex', gap: '10px', marginTop: '18px', flexWrap: 'wrap' }}>
<button className="btn btn-primary" type="button" onClick={() => setShowConnect(true)} disabled={!status}>{status?.provider ? 'Change storage' : 'Connect storage'}</button>
{status?.provider === 'stuffbox' && <button className="btn btn-ghost" type="button" onClick={disconnectStuffbox} disabled={isDisconnecting}>{isDisconnecting ? 'Disconnecting…' : 'Disconnect Stuffbox'}</button>}
</div>
</div>
{error && <p style={{ color: 'var(--error)', fontSize: '14px', marginTop: '14px' }}>{error}</p>}
</main>
<StorageConfigurationPrompt open={showConnect} onConfigured={async () => { setShowConnect(false); await loadStatus(); }} onCancel={() => setShowConnect(false)} />
</>;
}
+13 -21
View File
@@ -8,6 +8,7 @@ import { VideoEmbed } from '@/components/VideoEmbed';
import { useFormattedHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
interface MediaAttachment extends Attachment { interface MediaAttachment extends Attachment {
mimeType?: string; mimeType?: string;
@@ -131,31 +132,22 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
for (const file of selectedFiles) { for (const file of selectedFiles) {
try { try {
const formData = new FormData(); const media = await uploadMediaFile(file);
formData.append('file', file);
const res = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok || !data.media?.id) {
if (data.code === 'STORAGE_NOT_CONFIGURED') {
setPendingStorageFiles(selectedFiles);
setShowStorageConfiguration(true);
setIsUploading(false);
return;
}
throw new Error(data.error || 'Upload failed');
}
uploaded.push({ uploaded.push({
id: data.media.id, id: media.id,
url: data.media.url || data.url, url: media.url,
altText: data.media.altText ?? null, altText: media.altText ?? null,
mimeType: data.media.mimeType ?? file.type, mimeType: media.mimeType ?? file.type,
}); });
} catch (error) { } catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED') {
setAttachments((prev) => [...prev, ...uploaded].slice(0, 4));
setPendingStorageFiles(selectedFiles.slice(uploaded.length));
setShowStorageConfiguration(true);
setIsUploading(false);
return;
}
console.error('Upload failed', error); console.error('Upload failed', error);
setUploadError('One or more uploads failed. Try again.'); setUploadError('One or more uploads failed. Try again.');
} }
+117 -91
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Box, ChevronDown, ExternalLink } from 'lucide-react';
interface StorageConfigurationPromptProps { interface StorageConfigurationPromptProps {
open: boolean; open: boolean;
@@ -8,11 +9,7 @@ interface StorageConfigurationPromptProps {
onCancel: () => void; onCancel: () => void;
} }
export function StorageConfigurationPrompt({ export function StorageConfigurationPrompt({ open, onConfigured, onCancel }: StorageConfigurationPromptProps) {
open,
onConfigured,
onCancel,
}: StorageConfigurationPromptProps) {
const [provider, setProvider] = useState('r2'); const [provider, setProvider] = useState('r2');
const [endpoint, setEndpoint] = useState(''); const [endpoint, setEndpoint] = useState('');
const [publicBaseUrl, setPublicBaseUrl] = useState(''); const [publicBaseUrl, setPublicBaseUrl] = useState('');
@@ -23,37 +20,81 @@ export function StorageConfigurationPrompt({
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [stuffboxAvailable, setStuffboxAvailable] = useState(false);
const [showS3, setShowS3] = useState(false);
useEffect(() => {
if (!open) return;
let active = true;
setError('');
setIsLoading(true);
fetch('/api/storage/configuration', { cache: 'no-store' })
.then(async (response) => {
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Unable to load storage options');
if (active) setStuffboxAvailable(Boolean(data.stuffboxAvailable));
})
.catch((loadError) => active && setError(loadError instanceof Error ? loadError.message : 'Unable to load storage options'))
.finally(() => active && setIsLoading(false));
return () => { active = false; };
}, [open]);
if (!open) return null; if (!open) return null;
const needsCustomUrls = provider === 'r2' || provider === 'b2' || provider === 'contabo'; const needsCustomUrls = provider === 'r2' || provider === 'b2' || provider === 'contabo';
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const connectStuffbox = async () => {
setError('');
setIsSubmitting(true);
const popup = window.open('', 'synapsis-stuffbox', 'popup,width=620,height=760');
if (!popup) {
setError('Your browser blocked the Stuffbox window. Allow popups and try again.');
setIsSubmitting(false);
return;
}
popup.document.body.textContent = 'Connecting to Stuffbox…';
try {
const response = await fetch('/api/storage/stuffbox/connect', { method: 'POST' });
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.authorizationUrl) throw new Error(data.error || 'Unable to connect Stuffbox');
const result = new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
window.removeEventListener('message', receive);
reject(new Error('Stuffbox connection timed out.'));
}, 10 * 60_000);
function receive(event: MessageEvent) {
if (event.origin !== window.location.origin || event.data?.type !== 'synapsis:stuffbox') return;
window.clearTimeout(timeout);
window.removeEventListener('message', receive);
if (event.data.success) resolve();
else reject(new Error(event.data.message || 'Stuffbox could not be connected.'));
}
window.addEventListener('message', receive);
});
popup.location.href = data.authorizationUrl;
await result;
await onConfigured();
} catch (connectError) {
if (!popup.closed) popup.close();
setError(connectError instanceof Error ? connectError.message : 'Unable to connect Stuffbox');
} finally {
setIsSubmitting(false);
}
};
const connectS3 = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const response = await fetch('/api/storage/configuration', { const response = await fetch('/api/storage/configuration', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ provider, endpoint: endpoint || null, publicBaseUrl: publicBaseUrl || null, region, bucket, accessKey, secretKey, password }),
provider,
endpoint: endpoint || null,
publicBaseUrl: publicBaseUrl || null,
region,
bucket,
accessKey,
secretKey,
password,
}),
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Failed to connect storage');
if (!response.ok) {
throw new Error(data.error || 'Failed to connect storage');
}
await onConfigured(); await onConfigured();
} catch (submitError) { } catch (submitError) {
setError(submitError instanceof Error ? submitError.message : 'Failed to connect storage'); setError(submitError instanceof Error ? submitError.message : 'Failed to connect storage');
@@ -63,83 +104,68 @@ export function StorageConfigurationPrompt({
}; };
return ( return (
<div <div style={{ position: 'fixed', inset: 0, zIndex: 100000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px', background: 'rgba(0, 0, 0, 0.8)' }} onClick={onCancel}>
style={{
position: 'fixed',
inset: 0,
zIndex: 100000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
background: 'rgba(0, 0, 0, 0.8)',
}}
onClick={onCancel}
>
<div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}> <div className="card" style={{ width: '100%', maxWidth: '560px', maxHeight: '90vh', overflowY: 'auto', padding: '24px' }} onClick={(event) => event.stopPropagation()}>
<h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect your media storage</h3> <h3 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '8px' }}>Connect media storage</h3>
<p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}> <p style={{ color: 'var(--foreground-secondary)', lineHeight: 1.5, marginBottom: '20px' }}>
Synapsis keeps media in your own S3-compatible bucket. This is only required when you upload a photo or video. Your media lives outside this Synapsis node, so your account stays portable.
</p> </p>
<form onSubmit={handleSubmit}> <div style={{ border: '1px solid var(--border)', borderRadius: '12px', padding: '18px', background: 'var(--background-secondary)' }}>
<label style={{ display: 'block', marginBottom: '12px' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Provider</span> <Box size={24} style={{ flex: '0 0 auto', marginTop: '2px' }} />
<select className="input" value={provider} onChange={(event) => setProvider(event.target.value)}> <div style={{ flex: 1 }}>
<option value="r2">Cloudflare R2</option> <div style={{ fontWeight: 600, marginBottom: '4px' }}>Stuffbox</div>
<option value="b2">Backblaze B2</option> <div style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.45 }}>
<option value="wasabi">Wasabi</option> Connect once, then upload directly from your browser. Synapsis never sees your storage credentials.
<option value="contabo">Contabo S3</option> </div>
<option value="s3">AWS S3</option>
</select>
</label>
{needsCustomUrls && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>S3 endpoint</span>
<input className="input" value={endpoint} onChange={(event) => setEndpoint(event.target.value)} placeholder="https://..." required />
</label>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Public media URL</span>
<input className="input" value={publicBaseUrl} onChange={(event) => setPublicBaseUrl(event.target.value)} placeholder="https://..." required />
</label>
</div> </div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Region</span>
<input className="input" value={region} onChange={(event) => setRegion(event.target.value)} required />
</label>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Bucket</span>
<input className="input" value={bucket} onChange={(event) => setBucket(event.target.value)} required />
</label>
</div> </div>
<button type="button" className="btn btn-primary" onClick={connectStuffbox} disabled={isSubmitting || isLoading || !stuffboxAvailable} style={{ width: '100%', marginTop: '16px' }}>
{isSubmitting && !showS3 ? 'Connecting…' : stuffboxAvailable ? <>Connect Stuffbox <ExternalLink size={15} /></> : isLoading ? 'Loading…' : 'Stuffbox unavailable on this node'}
</button>
</div>
<label style={{ display: 'block', marginBottom: '12px' }}> <button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} style={{ width: '100%', marginTop: '12px', justifyContent: 'space-between' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Access key ID</span> Use your own S3-compatible bucket <ChevronDown size={16} style={{ transform: showS3 ? 'rotate(180deg)' : undefined }} />
<input className="input" type="password" value={accessKey} onChange={(event) => setAccessKey(event.target.value)} required minLength={10} /> </button>
</label>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Secret access key</span>
<input className="input" type="password" value={secretKey} onChange={(event) => setSecretKey(event.target.value)} required minLength={10} />
</label>
<label style={{ display: 'block', marginBottom: '12px' }}>
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Synapsis account password</span>
<input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
<span style={{ display: 'block', marginTop: '5px', fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Used locally to encrypt these credentials before they are saved.</span>
</label>
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>{error}</div>} {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>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}> {error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button> <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div>
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>{isSubmitting ? 'Connecting...' : 'Connect and upload'}</button>
</div>
</form>
</div> </div>
</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>;
}
+14 -28
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import { StorageSessionPrompt } from '@/components/StorageSessionPrompt'; import { StorageSessionPrompt } from '@/components/StorageSessionPrompt';
import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt';
import { refreshStorageSession } from '@/lib/storage/client'; import { refreshStorageSession } from '@/lib/storage/client';
import { MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload';
interface UserStorageImageUploadProps { interface UserStorageImageUploadProps {
label: string; label: string;
@@ -45,41 +46,26 @@ export function UserStorageImageUpload({
setIsUploading(true); setIsUploading(true);
try { try {
const formData = new FormData(); const media = await uploadMediaFile(file);
formData.append('file', file);
const res = await fetch('/api/media/upload', { onChange(media.url);
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok || !data.url) {
const message = data.error || 'Upload failed';
if (data.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingFile(file);
setShowConfigurationPrompt(true);
return;
}
if (res.status === 401 && allowPrompt) {
setPendingFile(file);
setPromptError('');
setShowSessionPrompt(true);
return;
}
throw new Error(message);
}
onChange(data.media?.url || data.url);
onError?.(''); onError?.('');
setPendingFile(null); setPendingFile(null);
setShowSessionPrompt(false); setShowSessionPrompt(false);
setPassword(''); setPassword('');
setPromptError(''); setPromptError('');
} catch (error) { } catch (error) {
if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED' && allowPrompt) {
setPendingFile(file);
setShowConfigurationPrompt(true);
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);
+11
View File
@@ -15,6 +15,10 @@ export const relations = defineRelations(schema, (r) => ({
alias: 'ownedBots', alias: 'ownedBots',
}), }),
posts: r.many.posts({ from: r.users.id, to: r.posts.userId }), posts: r.many.posts({ from: r.users.id, to: r.posts.userId }),
stuffboxConnection: r.one.stuffboxConnections({
from: r.users.id,
to: r.stuffboxConnections.userId,
}),
followersRelation: r.many.follows({ followersRelation: r.many.follows({
from: r.users.id, from: r.users.id,
to: r.follows.followingId, to: r.follows.followingId,
@@ -57,6 +61,13 @@ export const relations = defineRelations(schema, (r) => ({
user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }), user: r.one.users({ from: r.media.userId, to: r.users.id, optional: false }),
post: r.one.posts({ from: r.media.postId, to: r.posts.id }), post: r.one.posts({ from: r.media.postId, to: r.posts.id }),
}, },
stuffboxConnections: {
user: r.one.users({
from: r.stuffboxConnections.userId,
to: r.users.id,
optional: false,
}),
},
follows: { follows: {
follower: r.one.users({ follower: r.one.users({
from: r.follows.followerId, from: r.follows.followerId,
+18
View File
@@ -97,6 +97,22 @@ export const users = sqliteTable('users', {
}).onDelete('cascade'), }).onDelete('cascade'),
]); ]);
// ============================================
// STUFFBOX CONNECTIONS
// ============================================
export const stuffboxConnections = sqliteTable('stuffbox_connections', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
baseUrl: text('base_url').notNull(),
accessTokenEncrypted: text('access_token_encrypted').notNull(),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }).notNull(),
refreshTokenEncrypted: text('refresh_token_encrypted').notNull(),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
scopes: text('scopes').notNull(),
connectedAt: integer('connected_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).default(currentTimestamp).notNull(),
});
// ============================================ // ============================================
// POSTS // POSTS
@@ -155,6 +171,8 @@ export const media = sqliteTable('media', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }), postId: text('post_id').references(() => posts.id, { onDelete: 'cascade' }),
url: text('url').notNull(), url: text('url').notNull(),
storageProvider: text('storage_provider'),
storageAssetId: text('storage_asset_id'),
altText: text('alt_text'), altText: text('alt_text'),
mimeType: text('mime_type'), mimeType: text('mime_type'),
width: integer('width'), width: integer('width'),
+117
View File
@@ -0,0 +1,117 @@
'use client';
export interface UploadedMedia {
id: string;
url: string;
altText?: string | null;
mimeType?: string | null;
}
export class MediaUploadError extends Error {
constructor(
message: string,
readonly code?: string,
readonly status?: number,
) {
super(message);
this.name = 'MediaUploadError';
}
}
interface JsonResponse extends Record<string, unknown> {
id?: string;
uploadUrl?: string;
requiredHeaders?: Record<string, string>;
media?: UploadedMedia;
error?: string;
code?: string;
provider?: string | null;
}
async function json(response: Response): Promise<JsonResponse> {
return response.json().catch(() => ({})) as Promise<JsonResponse>;
}
function directPut(
uploadUrl: string,
file: File,
headers: Record<string, string>,
onProgress?: (progress: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('PUT', uploadUrl);
for (const [name, value] of Object.entries(headers)) request.setRequestHeader(name, value);
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) onProgress?.(event.loaded / event.total);
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
onProgress?.(1);
resolve();
} else {
reject(new MediaUploadError(`Stuffbox rejected the upload (${request.status})`, 'DIRECT_UPLOAD_FAILED', request.status));
}
});
request.addEventListener('error', () => reject(new MediaUploadError(
'The browser could not reach Stuffbox. Check its CORS and public URL settings.',
'DIRECT_UPLOAD_FAILED',
)));
request.addEventListener('abort', () => reject(new MediaUploadError('Upload cancelled', 'UPLOAD_CANCELLED')));
request.send(file);
});
}
async function uploadToStuffbox(file: File, onProgress?: (progress: number) => void): Promise<UploadedMedia> {
const sessionResponse = await fetch('/api/media/stuffbox/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, mimeType: file.type, size: file.size }),
});
const session = await json(sessionResponse);
if (!sessionResponse.ok) {
throw new MediaUploadError(session.error || 'Unable to start upload', session.code, sessionResponse.status);
}
if (!session.id || !session.uploadUrl) {
throw new MediaUploadError('Stuffbox returned an invalid upload session', 'INVALID_UPLOAD_SESSION');
}
await directPut(session.uploadUrl, file, session.requiredHeaders || {}, onProgress);
const completeResponse = await fetch(`/api/media/stuffbox/uploads/${encodeURIComponent(session.id)}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const complete = await json(completeResponse);
if (!completeResponse.ok || !complete.media?.url) {
throw new MediaUploadError(complete.error || 'Unable to finish upload', complete.code, completeResponse.status);
}
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(
file: File,
onProgress?: (progress: number) => void,
): Promise<UploadedMedia> {
const configurationResponse = await fetch('/api/storage/configuration', { cache: 'no-store' });
const configuration = await json(configurationResponse);
if (!configurationResponse.ok) {
throw new MediaUploadError(configuration.error || 'Unable to load storage configuration', undefined, configurationResponse.status);
}
if (configuration.provider === 'stuffbox') return uploadToStuffbox(file, onProgress);
if (configuration.provider === 's3') return uploadToS3(file);
throw new MediaUploadError('Connect media storage before uploading.', 'STORAGE_NOT_CONFIGURED', 409);
}
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createUpload, exchangeAuthorizationCode } from './client';
afterEach(() => vi.unstubAllGlobals());
describe('Stuffbox client', () => {
it('exchanges an authorization code and accepts the v1 snake-case response', async () => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
expires_in: 900,
refresh_token_expires_in: 2_592_000,
scope: 'assets:read assets:write',
}), { status: 200, headers: { 'Content-Type': 'application/json' } }));
vi.stubGlobal('fetch', fetch);
const tokens = await exchangeAuthorizationCode('https://stuffbox.test/', {
code: 'authorization-code',
codeVerifier: 'verifier',
redirectUri: 'https://synapsis.test/api/storage/stuffbox/callback',
});
expect(tokens).toEqual({
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 900,
refreshTokenExpiresIn: 2_592_000,
scopes: ['assets:read', 'assets:write'],
});
expect(fetch).toHaveBeenCalledWith('https://stuffbox.test/api/v1/token', expect.objectContaining({ method: 'POST' }));
});
it('normalizes a direct upload session without exposing the bearer token to the browser response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: {
upload_id: 'upload-1',
upload_url: 'https://objects.test/upload-1',
required_headers: { 'Content-Type': 'image/png', 'x-upload-token': 'one-time' },
expires_at: '2026-07-15T00:00:00Z',
} }), { status: 201, headers: { 'Content-Type': 'application/json' } })));
await expect(createUpload('https://stuffbox.test', 'secret-access-token', {
filename: 'photo.png', mimeType: 'image/png', size: 123,
})).resolves.toEqual({
id: 'upload-1',
uploadUrl: 'https://objects.test/upload-1',
method: 'PUT',
requiredHeaders: { 'Content-Type': 'image/png', 'x-upload-token': 'one-time' },
expiresAt: '2026-07-15T00:00:00Z',
});
});
it('preserves structured Stuffbox errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: { code: 'invalid_grant', message: 'Connection expired' },
}), { status: 401, headers: { 'Content-Type': 'application/json' } })));
await expect(exchangeAuthorizationCode('https://stuffbox.test', {
code: 'bad', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/callback',
})).rejects.toMatchObject({
message: 'Connection expired', status: 401, code: 'invalid_grant',
});
});
});
+230
View File
@@ -0,0 +1,230 @@
import type {
StuffboxAsset,
StuffboxScope,
StuffboxTokenSet,
StuffboxUploadSession,
} from './types';
type JsonObject = Record<string, unknown>;
export class StuffboxApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string,
) {
super(message);
this.name = 'StuffboxApiError';
}
}
function object(value: unknown): JsonObject {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new StuffboxApiError('Stuffbox returned an invalid response', 502, 'invalid_response');
}
const record = value as JsonObject;
return record.data && typeof record.data === 'object' && !Array.isArray(record.data)
? record.data as JsonObject
: record;
}
function string(record: JsonObject, ...keys: string[]): string {
for (const key of keys) {
if (typeof record[key] === 'string' && record[key]) return record[key] as string;
}
throw new StuffboxApiError(`Stuffbox response is missing ${keys[0]}`, 502, 'invalid_response');
}
function number(record: JsonObject, ...keys: string[]): number {
for (const key of keys) {
if (typeof record[key] === 'number' && Number.isFinite(record[key])) return record[key] as number;
}
throw new StuffboxApiError(`Stuffbox response is missing ${keys[0]}`, 502, 'invalid_response');
}
function optionalString(record: JsonObject, ...keys: string[]): string | undefined {
for (const key of keys) {
if (typeof record[key] === 'string') return record[key] as string;
}
return undefined;
}
function parseScopes(value: unknown): StuffboxScope[] {
const scopes = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(/\s+/).filter(Boolean)
: [];
return scopes.filter((scope): scope is StuffboxScope =>
scope === 'assets:read' || scope === 'assets:write' || scope === 'assets:delete');
}
async function request(baseUrl: string, path: string, init: RequestInit): Promise<unknown> {
let response: Response;
try {
response = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
...init,
headers: { Accept: 'application/json', ...init.headers },
cache: 'no-store',
});
} catch (cause) {
throw new StuffboxApiError(
cause instanceof Error ? `Unable to reach Stuffbox: ${cause.message}` : 'Unable to reach Stuffbox',
502,
'network_error',
);
}
const body = await response.json().catch(() => undefined);
if (!response.ok) {
const root = body && typeof body === 'object' ? body as JsonObject : {};
const error = root.error && typeof root.error === 'object' ? root.error as JsonObject : root;
throw new StuffboxApiError(
typeof error.message === 'string' ? error.message : `Stuffbox request failed (${response.status})`,
response.status,
typeof error.code === 'string' ? error.code : `http_${response.status}`,
);
}
return body;
}
export function configuredStuffboxUrl(): string | null {
const value = process.env.STUFFBOX_URL?.trim();
if (!value) return null;
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('STUFFBOX_URL must use HTTP or HTTPS');
return url.toString().replace(/\/$/, '');
}
export async function createConnectionRequest(baseUrl: string, input: {
nodeName: string;
callbackUrl: string;
codeChallenge: string;
state: string;
scopes: readonly StuffboxScope[];
}): Promise<{ id: string; authorizationUrl: string; expiresAt: string }> {
const data = object(await request(baseUrl, '/api/v1/connection-requests', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
node_name: input.nodeName,
callback_url: input.callbackUrl,
code_challenge: input.codeChallenge,
code_challenge_method: 'S256',
scopes: input.scopes,
state: input.state,
}),
}));
return {
id: string(data, 'id', 'request_id'),
authorizationUrl: string(data, 'authorizationUrl', 'authorization_url'),
expiresAt: string(data, 'expiresAt', 'expires_at'),
};
}
export async function exchangeAuthorizationCode(baseUrl: string, input: {
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<StuffboxTokenSet> {
return parseTokenSet(await request(baseUrl, '/api/v1/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: input.code,
code_verifier: input.codeVerifier,
redirect_uri: input.redirectUri,
}),
}));
}
export async function refreshTokens(baseUrl: string, refreshToken: string): Promise<StuffboxTokenSet> {
return parseTokenSet(await request(baseUrl, '/api/v1/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: refreshToken }),
}));
}
function parseTokenSet(value: unknown): StuffboxTokenSet {
const data = object(value);
const refreshTokenExpiresIn = data.refresh_token_expires_in ?? data.refreshTokenExpiresIn;
return {
accessToken: string(data, 'accessToken', 'access_token'),
refreshToken: string(data, 'refreshToken', 'refresh_token'),
expiresIn: number(data, 'expiresIn', 'expires_in'),
...(typeof refreshTokenExpiresIn === 'number' ? { refreshTokenExpiresIn } : {}),
scopes: parseScopes(data.scopes ?? data.scope),
};
}
export async function revokeToken(baseUrl: string, token: string): Promise<void> {
await request(baseUrl, '/api/v1/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, token_type_hint: 'refresh_token' }),
});
}
export async function createUpload(baseUrl: string, accessToken: string, input: {
filename: string;
mimeType: string;
size: number;
sha256?: string;
}): Promise<StuffboxUploadSession> {
const data = object(await request(baseUrl, '/api/v1/uploads', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({
filename: input.filename,
mime_type: input.mimeType,
size: input.size,
...(input.sha256 ? { sha256: input.sha256 } : {}),
}),
}));
const headers = data.requiredHeaders ?? data.required_headers ?? {};
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
throw new StuffboxApiError('Stuffbox returned invalid upload headers', 502, 'invalid_response');
}
return {
id: string(data, 'id', 'upload_id'),
uploadUrl: string(data, 'uploadUrl', 'upload_url'),
method: 'PUT',
requiredHeaders: Object.fromEntries(
Object.entries(headers).filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
),
expiresAt: string(data, 'expiresAt', 'expires_at'),
};
}
export async function completeUpload(
baseUrl: string,
accessToken: string,
uploadId: string,
): Promise<StuffboxAsset> {
const data = object(await request(baseUrl, `/api/v1/uploads/${encodeURIComponent(uploadId)}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
body: '{}',
}));
const asset = data.asset && typeof data.asset === 'object' ? data.asset as JsonObject : data;
const status = optionalString(asset, 'status') ?? 'active';
if (status !== 'active' && status !== 'deleting' && status !== 'deleted') {
throw new StuffboxApiError('Stuffbox returned an invalid asset status', 502, 'invalid_response');
}
return {
id: string(asset, 'id', 'asset_id'),
publicId: string(asset, 'publicId', 'public_id'),
url: string(asset, 'url', 'canonical_url'),
filename: string(asset, 'filename', 'original_filename'),
mimeType: string(asset, 'mimeType', 'mime_type'),
byteSize: number(asset, 'byteSize', 'byte_size'),
...(optionalString(asset, 'sha256') ? { sha256: optionalString(asset, 'sha256') } : {}),
status,
createdAt: string(asset, 'createdAt', 'created_at'),
...(optionalString(asset, 'deletedAt', 'deleted_at')
? { deletedAt: optionalString(asset, 'deletedAt', 'deleted_at') }
: {}),
};
}
+38
View File
@@ -0,0 +1,38 @@
import { cookies } from 'next/headers';
import { openStuffboxSecret, sealStuffboxSecret } from './crypto';
const COOKIE_NAME = 'synapsis_stuffbox_connect';
const CONTEXT = 'stuffbox-connection-state';
export interface StuffboxConnectionState {
userId: string;
baseUrl: string;
verifier: string;
state: string;
redirectUri: string;
expiresAt: number;
}
export async function saveStuffboxConnectionState(value: StuffboxConnectionState): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, sealStuffboxSecret(JSON.stringify(value), CONTEXT), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/storage/stuffbox/callback',
expires: new Date(value.expiresAt),
});
}
export async function consumeStuffboxConnectionState(): Promise<StuffboxConnectionState | null> {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
cookieStore.delete(COOKIE_NAME);
if (!token) return null;
try {
const value = JSON.parse(openStuffboxSecret(token, CONTEXT)) as StuffboxConnectionState;
return value.expiresAt > Date.now() ? value : null;
} catch {
return null;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { generatePkce, openStuffboxSecret, sealStuffboxSecret } from './crypto';
const previousSecret = process.env.AUTH_SECRET;
beforeAll(() => { process.env.AUTH_SECRET = 'stuffbox-test-secret-at-least-16-characters'; });
afterAll(() => {
if (previousSecret === undefined) delete process.env.AUTH_SECRET;
else process.env.AUTH_SECRET = previousSecret;
});
describe('Stuffbox secret protection', () => {
it('round-trips secrets only with the matching context', () => {
const sealed = sealStuffboxSecret('refresh-token', 'stuffbox:user-1:refresh');
expect(openStuffboxSecret(sealed, 'stuffbox:user-1:refresh')).toBe('refresh-token');
expect(() => openStuffboxSecret(sealed, 'stuffbox:user-2:refresh')).toThrow();
});
it('creates an S256 PKCE pair and independent state', async () => {
const pkce = generatePkce();
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(pkce.verifier));
const challenge = Buffer.from(digest).toString('base64url');
expect(pkce.challenge).toBe(challenge);
expect(pkce.state).not.toBe(pkce.verifier);
expect(pkce.verifier.length).toBeGreaterThanOrEqual(43);
});
});
+33
View File
@@ -0,0 +1,33 @@
import crypto from 'node:crypto';
function key(): Buffer {
const secret = process.env.AUTH_SECRET;
if (!secret || secret.length < 16) throw new Error('AUTH_SECRET is required for Stuffbox');
return crypto.createHash('sha256').update(`stuffbox:${secret}`).digest();
}
export function sealStuffboxSecret(value: string, context: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
cipher.setAAD(Buffer.from(context));
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
}
export function openStuffboxSecret(value: string, context: string): string {
const raw = Buffer.from(value, 'base64url');
if (raw.length < 29) throw new Error('Invalid encrypted Stuffbox value');
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), raw.subarray(0, 12));
decipher.setAAD(Buffer.from(context));
decipher.setAuthTag(raw.subarray(12, 28));
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
}
export function generatePkce(): { verifier: string; challenge: string; state: string } {
const verifier = crypto.randomBytes(48).toString('base64url');
return {
verifier,
challenge: crypto.createHash('sha256').update(verifier).digest('base64url'),
state: crypto.randomBytes(32).toString('base64url'),
};
}
+85
View File
@@ -0,0 +1,85 @@
import { eq } from 'drizzle-orm';
import { db, stuffboxConnections } from '@/db';
import { openStuffboxSecret, sealStuffboxSecret } from './crypto';
import { refreshTokens, type StuffboxApiError } from './client';
import type { StuffboxTokenSet } from './types';
const refreshes = new Map<string, Promise<{ baseUrl: string; accessToken: string }>>();
function encrypted(token: string, userId: string, kind: 'access' | 'refresh'): string {
return sealStuffboxSecret(token, `stuffbox:${userId}:${kind}`);
}
function decrypted(token: string, userId: string, kind: 'access' | 'refresh'): string {
return openStuffboxSecret(token, `stuffbox:${userId}:${kind}`);
}
export async function saveStuffboxTokens(
userId: string,
baseUrl: string,
tokens: StuffboxTokenSet,
): Promise<void> {
const now = Date.now();
const values = {
userId,
baseUrl,
accessTokenEncrypted: encrypted(tokens.accessToken, userId, 'access'),
accessTokenExpiresAt: new Date(now + tokens.expiresIn * 1000),
refreshTokenEncrypted: encrypted(tokens.refreshToken, userId, 'refresh'),
refreshTokenExpiresAt: tokens.refreshTokenExpiresIn
? new Date(now + tokens.refreshTokenExpiresIn * 1000)
: null,
scopes: JSON.stringify(tokens.scopes),
updatedAt: new Date(),
};
await db.insert(stuffboxConnections).values(values).onConflictDoUpdate({
target: stuffboxConnections.userId,
set: values,
});
}
export async function getStuffboxAccess(userId: string): Promise<{ baseUrl: string; accessToken: string }> {
const connection = await db.query.stuffboxConnections.findFirst({ where: { userId } });
if (!connection) throw new Error('STUFFBOX_NOT_CONNECTED');
if (connection.accessTokenExpiresAt.getTime() > Date.now() + 60_000) {
return {
baseUrl: connection.baseUrl,
accessToken: decrypted(connection.accessTokenEncrypted, userId, 'access'),
};
}
const existing = refreshes.get(userId);
if (existing) return existing;
const operation = (async () => {
try {
const refreshToken = decrypted(connection.refreshTokenEncrypted, userId, 'refresh');
const tokens = await refreshTokens(connection.baseUrl, refreshToken);
await saveStuffboxTokens(userId, connection.baseUrl, tokens);
return { baseUrl: connection.baseUrl, accessToken: tokens.accessToken };
} catch (error) {
const apiError = error as StuffboxApiError;
if (apiError?.status === 401 || apiError?.code === 'refresh_token_reuse') {
await db.delete(stuffboxConnections).where(eq(stuffboxConnections.userId, userId));
}
throw error;
} finally {
refreshes.delete(userId);
}
})();
refreshes.set(userId, operation);
return operation;
}
export async function getStuffboxConnection(userId: string) {
return db.query.stuffboxConnections.findFirst({ where: { userId } });
}
export async function removeStuffboxConnection(userId: string): Promise<void> {
await db.delete(stuffboxConnections).where(eq(stuffboxConnections.userId, userId));
}
export function readStuffboxRefreshToken(connection: typeof stuffboxConnections.$inferSelect): string {
return decrypted(connection.refreshTokenEncrypted, connection.userId, 'refresh');
}
+32
View File
@@ -0,0 +1,32 @@
export const STUFFBOX_SCOPES = ['assets:write'] as const;
export type StuffboxScope = (typeof STUFFBOX_SCOPES)[number];
export interface StuffboxTokenSet {
accessToken: string;
refreshToken: string;
expiresIn: number;
refreshTokenExpiresIn?: number;
scopes: StuffboxScope[];
}
export interface StuffboxUploadSession {
id: string;
uploadUrl: string;
method: 'PUT';
requiredHeaders: Record<string, string>;
expiresAt: string;
}
export interface StuffboxAsset {
id: string;
publicId: string;
url: string;
filename: string;
mimeType: string;
byteSize: number;
sha256?: string;
status: 'active' | 'deleting' | 'deleted';
createdAt: string;
deletedAt?: string;
}