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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
+12
-20
@@ -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) {
|
uploaded.push({
|
||||||
if (data.code === 'STORAGE_NOT_CONFIGURED') {
|
id: media.id,
|
||||||
setPendingStorageFiles(selectedFiles);
|
url: media.url,
|
||||||
|
altText: media.altText ?? null,
|
||||||
|
mimeType: media.mimeType ?? file.type,
|
||||||
|
});
|
||||||
|
} 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);
|
setShowStorageConfiguration(true);
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
uploaded.push({
|
|
||||||
id: data.media.id,
|
|
||||||
url: data.media.url || data.url,
|
|
||||||
altText: data.media.altText ?? null,
|
|
||||||
mimeType: data.media.mimeType ?? file.type,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
|
||||||
|
<Box size={24} style={{ flex: '0 0 auto', marginTop: '2px' }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '4px' }}>Stuffbox</div>
|
||||||
|
<div style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.45 }}>
|
||||||
|
Connect once, then upload directly from your browser. Synapsis never sees your storage credentials.
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => { setShowS3((value) => !value); setError(''); }} 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' }}>
|
<label style={{ display: 'block', marginBottom: '12px' }}>
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Provider</span>
|
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Provider</span>
|
||||||
<select className="input" value={provider} onChange={(event) => setProvider(event.target.value)}>
|
<select className="input" value={provider} onChange={(event) => setProvider(event.target.value)}>
|
||||||
<option value="r2">Cloudflare R2</option>
|
<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>
|
||||||
<option value="b2">Backblaze B2</option>
|
|
||||||
<option value="wasabi">Wasabi</option>
|
|
||||||
<option value="contabo">Contabo S3</option>
|
|
||||||
<option value="s3">AWS S3</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{needsCustomUrls && <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
{needsCustomUrls && (
|
<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' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
<Field label="Region" value={region} onChange={setRegion} />
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>S3 endpoint</span>
|
<Field label="Bucket" value={bucket} onChange={setBucket} />
|
||||||
<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>
|
||||||
|
<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: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginTop: '14px' }}>{error}</div>}
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '16px' }}><button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button></div>
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Region</span>
|
|
||||||
<input className="input" value={region} onChange={(event) => setRegion(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Bucket</span>
|
|
||||||
<input className="input" value={bucket} onChange={(event) => setBucket(event.target.value)} required />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Access key ID</span>
|
|
||||||
<input className="input" type="password" value={accessKey} onChange={(event) => setAccessKey(event.target.value)} required minLength={10} />
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Secret access key</span>
|
|
||||||
<input className="input" type="password" value={secretKey} onChange={(event) => setSecretKey(event.target.value)} required minLength={10} />
|
|
||||||
</label>
|
|
||||||
<label style={{ display: 'block', marginBottom: '12px' }}>
|
|
||||||
<span style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: 500 }}>Synapsis account password</span>
|
|
||||||
<input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
|
||||||
<span style={{ display: 'block', marginTop: '5px', fontSize: '12px', color: 'var(--foreground-tertiary)' }}>Used locally to encrypt these credentials before they are saved.</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && <div style={{ color: 'var(--error)', fontSize: '13px', marginBottom: '12px' }}>{error}</div>}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={isSubmitting}>Cancel</button>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>{isSubmitting ? 'Connecting...' : 'Connect and upload'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</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>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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') }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user