diff --git a/.env.example b/.env.example index fe7dfaa..51258ac 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,8 @@ ADMIN_EMAILS=admin@example.com NEXT_PUBLIC_NODE_DOMAIN=localhost:43821 NEXT_PUBLIC_APP_URL=http://localhost:43821 -# Recommended: Stuffbox instance offered as the default user-owned media provider +# Recommended: Stuffbox instance offered as the default user-owned media provider. +# Synapsis registers its callback automatically; no Stuffbox client ID is required. STUFFBOX_URL=https://stuffbox.xyz # Optional: node metadata shown before a node record exists in the database diff --git a/README.md b/README.md index 6ea5bd6..cd6a791 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Uninstalling preserves the database and environment by default. Pass `--purge-da The node database is a local embedded Turso/SQLite file. Media remains in storage controlled by each user, so exported accounts retain portable media URLs and can move between Synapsis nodes without requiring the old node to transfer a shared upload directory. -Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox. +Stuffbox is the default integration. New installs use `https://stuffbox.xyz`; set `STUFFBOX_URL` to a different public URL when using another or self-hosted Stuffbox service. Each Synapsis install registers its callback automatically the first time a user connects, so node operators do not need to create or configure a Stuffbox client ID. Synapsis uses a consent and PKCE flow, keeps the resulting tokens encrypted with `AUTH_SECRET`, and sends file bytes directly from the user's browser to Stuffbox. User-owned S3-compatible storage remains available as an advanced fallback. Supported providers include AWS S3, Cloudflare R2, Backblaze B2, Wasabi, and Contabo. diff --git a/src/app/api/storage/stuffbox/callback/route.test.ts b/src/app/api/storage/stuffbox/callback/route.test.ts new file mode 100644 index 0000000..55d6c40 --- /dev/null +++ b/src/app/api/storage/stuffbox/callback/route.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { exchangeAuthorizationCode } from '@/lib/stuffbox/client'; +import { consumeStuffboxConnectionState } from '@/lib/stuffbox/connection-state'; +import { renderStuffboxPopupResponse } from '@/lib/stuffbox/popup-response'; +import { saveStuffboxTokens } from '@/lib/stuffbox/tokens'; +import { GET } from './route'; + +vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() })); + +vi.mock('@/lib/stuffbox/client', () => { + class MockStuffboxApiError extends Error {} + + return { + exchangeAuthorizationCode: vi.fn(), + StuffboxApiError: MockStuffboxApiError, + }; +}); + +vi.mock('@/lib/stuffbox/connection-state', () => ({ consumeStuffboxConnectionState: vi.fn() })); +vi.mock('@/lib/stuffbox/popup-response', () => ({ renderStuffboxPopupResponse: vi.fn() })); +vi.mock('@/lib/stuffbox/tokens', () => ({ saveStuffboxTokens: vi.fn() })); + +describe('GET /api/storage/stuffbox/callback', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ id: 'user-1' } as never); + vi.mocked(consumeStuffboxConnectionState).mockResolvedValue({ + userId: 'user-1', + baseUrl: 'https://stuffbox.example', + clientId: 'client-returned-by-stuffbox', + verifier: 'pkce-verifier', + state: 'connection-state', + callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback', + expiresAt: Date.now() + 60_000, + }); + vi.mocked(exchangeAuthorizationCode).mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 900, + scopes: ['assets:write'], + }); + vi.mocked(saveStuffboxTokens).mockResolvedValue(undefined); + vi.mocked(renderStuffboxPopupResponse).mockReturnValue('connected'); + }); + + it('exchanges the code with the same client ID and callback saved at connect time', async () => { + const response = await GET(new NextRequest( + 'https://synapsis.example/api/storage/stuffbox/callback?code=authorization-code&state=connection-state', + )); + + expect(response.status).toBe(200); + expect(exchangeAuthorizationCode).toHaveBeenCalledWith('https://stuffbox.example', { + clientId: 'client-returned-by-stuffbox', + code: 'authorization-code', + codeVerifier: 'pkce-verifier', + redirectUri: 'https://canonical.synapsis.example/api/storage/stuffbox/callback', + }); + expect(saveStuffboxTokens).toHaveBeenCalledWith('user-1', 'https://stuffbox.example', { + accessToken: 'access-token', + refreshToken: 'refresh-token', + expiresIn: 900, + scopes: ['assets:write'], + }); + expect(renderStuffboxPopupResponse).toHaveBeenCalledWith( + 'https://canonical.synapsis.example', + true, + 'Stuffbox connected.', + 'connection-state', + ); + }); +}); diff --git a/src/app/api/storage/stuffbox/callback/route.ts b/src/app/api/storage/stuffbox/callback/route.ts index 198a8c6..6eac2dc 100644 --- a/src/app/api/storage/stuffbox/callback/route.ts +++ b/src/app/api/storage/stuffbox/callback/route.ts @@ -29,12 +29,13 @@ export async function GET(request: NextRequest) { } const tokens = await exchangeAuthorizationCode(pending.baseUrl, { + clientId: pending.clientId, code, codeVerifier: pending.verifier, - redirectUri: pending.redirectUri, + redirectUri: pending.callbackUrl, }); await saveStuffboxTokens(user.id, pending.baseUrl, tokens); - return popupResponse(new URL(pending.redirectUri).origin, true, 'Stuffbox connected.', attemptId); + return popupResponse(new URL(pending.callbackUrl).origin, true, 'Stuffbox connected.', attemptId); } catch (error) { if (error instanceof StuffboxApiError) { return popupResponse(origin, false, error.message, attemptId); diff --git a/src/app/api/storage/stuffbox/connect/route.test.ts b/src/app/api/storage/stuffbox/connect/route.test.ts new file mode 100644 index 0000000..d10bcce --- /dev/null +++ b/src/app/api/storage/stuffbox/connect/route.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { requireAuth } from '@/lib/auth'; +import { configuredStuffboxUrl, createConnectionRequest } from '@/lib/stuffbox/client'; +import { saveStuffboxConnectionState } from '@/lib/stuffbox/connection-state'; +import { generatePkce } from '@/lib/stuffbox/crypto'; +import { POST } from './route'; + +vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() })); + +vi.mock('@/lib/stuffbox/client', () => { + class MockStuffboxApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string, + ) { + super(message); + } + } + + return { + configuredStuffboxUrl: vi.fn(), + createConnectionRequest: vi.fn(), + StuffboxApiError: MockStuffboxApiError, + }; +}); + +vi.mock('@/lib/stuffbox/connection-state', () => ({ saveStuffboxConnectionState: vi.fn() })); +vi.mock('@/lib/stuffbox/crypto', () => ({ generatePkce: vi.fn() })); + +describe('POST /api/storage/stuffbox/connect', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-15T01:00:00.000Z')); + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://synapsis.example/app'); + vi.mocked(requireAuth).mockResolvedValue({ id: 'user-1' } as never); + vi.mocked(configuredStuffboxUrl).mockReturnValue('https://stuffbox.example'); + vi.mocked(generatePkce).mockReturnValue({ + challenge: 'pkce-challenge', + verifier: 'pkce-verifier', + state: 'connection-state', + }); + vi.mocked(createConnectionRequest).mockResolvedValue({ + id: 'request-1', + clientId: 'client-returned-by-stuffbox', + callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback', + authorizationUrl: 'https://stuffbox.example/connect/request-1', + expiresAt: '2026-07-15T01:05:00.000Z', + }); + vi.mocked(saveStuffboxConnectionState).mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + }); + + it('persists the client ID and canonical callback returned by Stuffbox', async () => { + const response = await POST(new NextRequest('https://synapsis.example/api/storage/stuffbox/connect', { + method: 'POST', + })); + + expect(response.status).toBe(200); + expect(createConnectionRequest).toHaveBeenCalledWith('https://stuffbox.example', { + callbackUrl: 'https://synapsis.example/api/storage/stuffbox/callback', + codeChallenge: 'pkce-challenge', + state: 'connection-state', + scopes: ['assets:write'], + }); + expect(saveStuffboxConnectionState).toHaveBeenCalledWith({ + userId: 'user-1', + baseUrl: 'https://stuffbox.example', + clientId: 'client-returned-by-stuffbox', + verifier: 'pkce-verifier', + state: 'connection-state', + callbackUrl: 'https://canonical.synapsis.example/api/storage/stuffbox/callback', + expiresAt: Date.parse('2026-07-15T01:05:00.000Z'), + }); + await expect(response.json()).resolves.toMatchObject({ + authorizationUrl: 'https://stuffbox.example/connect/request-1', + connectionAttempt: 'connection-state', + }); + }); +}); diff --git a/src/app/api/storage/stuffbox/connect/route.ts b/src/app/api/storage/stuffbox/connect/route.ts index fd1007d..a41b50d 100644 --- a/src/app/api/storage/stuffbox/connect/route.ts +++ b/src/app/api/storage/stuffbox/connect/route.ts @@ -1,5 +1,4 @@ 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'; @@ -27,14 +26,10 @@ export async function POST(request: NextRequest) { } 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 callbackUrl = `${origin}/api/storage/stuffbox/callback`; const pkce = generatePkce(); const connection = await createConnectionRequest(baseUrl, { - nodeName: node?.name || new URL(origin).host, - callbackUrl: redirectUri, + callbackUrl, codeChallenge: pkce.challenge, state: pkce.state, scopes: STUFFBOX_SCOPES, @@ -43,9 +38,10 @@ export async function POST(request: NextRequest) { await saveStuffboxConnectionState({ userId: user.id, baseUrl, + clientId: connection.clientId, verifier: pkce.verifier, state: pkce.state, - redirectUri, + callbackUrl: connection.callbackUrl, expiresAt: Math.min(Date.parse(connection.expiresAt) || Date.now() + 10 * 60_000, Date.now() + 10 * 60_000), }); diff --git a/src/lib/stuffbox/client.test.ts b/src/lib/stuffbox/client.test.ts index 296e91c..c3c9c7a 100644 --- a/src/lib/stuffbox/client.test.ts +++ b/src/lib/stuffbox/client.test.ts @@ -1,9 +1,77 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createUpload, exchangeAuthorizationCode } from './client'; +import { createConnectionRequest, createUpload, exchangeAuthorizationCode } from './client'; afterEach(() => vi.unstubAllGlobals()); describe('Stuffbox client', () => { + it('self-registers the exact callback and uses the canonical identity returned by Stuffbox', async () => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: { + request_id: 'request-1', + client_id: 'client-1', + callback_url: 'https://synapsis.test/api/storage/stuffbox/callback', + authorization_url: 'https://stuffbox.test/connect/request-1', + expires_at: '2026-07-15T00:10:00Z', + } }), { status: 201, headers: { 'Content-Type': 'application/json' } })); + vi.stubGlobal('fetch', fetch); + + await expect(createConnectionRequest('https://stuffbox.test/', { + callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback', + codeChallenge: 'challenge', + state: 'state', + scopes: ['assets:write'], + })).resolves.toEqual({ + id: 'request-1', + clientId: 'client-1', + callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback', + authorizationUrl: 'https://stuffbox.test/connect/request-1', + expiresAt: '2026-07-15T00:10:00Z', + }); + + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://stuffbox.test/api/v1/connection-requests'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ + registration_mode: 'self_hosted', + callback_url: 'https://synapsis.test/api/storage/stuffbox/callback', + code_challenge: 'challenge', + code_challenge_method: 'S256', + scopes: ['assets:write'], + state: 'state', + }); + }); + + it.each([ + ['client_id', { + request_id: 'request-1', + callback_url: 'https://synapsis.test/api/storage/stuffbox/callback', + authorization_url: 'https://stuffbox.test/connect/request-1', + expires_at: '2026-07-15T00:10:00Z', + }], + ['callback_url', { + request_id: 'request-1', + client_id: 'client-1', + authorization_url: 'https://stuffbox.test/connect/request-1', + expires_at: '2026-07-15T00:10:00Z', + }], + ])('rejects a connection response missing %s', async (field, data) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ data }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }))); + + await expect(createConnectionRequest('https://stuffbox.test', { + callbackUrl: 'https://synapsis.test/api/storage/stuffbox/callback', + codeChallenge: 'challenge', + state: 'state', + scopes: ['assets:write'], + })).rejects.toMatchObject({ + message: `Stuffbox response is missing ${field}`, + status: 502, + code: 'invalid_response', + }); + }); + 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', @@ -15,6 +83,7 @@ describe('Stuffbox client', () => { vi.stubGlobal('fetch', fetch); const tokens = await exchangeAuthorizationCode('https://stuffbox.test/', { + clientId: 'client-1', code: 'authorization-code', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/api/storage/stuffbox/callback', @@ -27,7 +96,17 @@ describe('Stuffbox client', () => { refreshTokenExpiresIn: 2_592_000, scopes: ['assets:read', 'assets:write'], }); - expect(fetch).toHaveBeenCalledWith('https://stuffbox.test/api/v1/token', expect.objectContaining({ method: 'POST' })); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://stuffbox.test/api/v1/token'); + expect(init.method).toBe('POST'); + expect(JSON.parse(String(init.body))).toEqual({ + grant_type: 'authorization_code', + client_id: 'client-1', + code: 'authorization-code', + code_verifier: 'verifier', + redirect_uri: 'https://synapsis.test/api/storage/stuffbox/callback', + }); }); it('normalizes a direct upload session without exposing the bearer token to the browser response', async () => { @@ -55,7 +134,7 @@ describe('Stuffbox client', () => { }), { status: 401, headers: { 'Content-Type': 'application/json' } }))); await expect(exchangeAuthorizationCode('https://stuffbox.test', { - code: 'bad', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/callback', + clientId: 'client-1', code: 'bad', codeVerifier: 'verifier', redirectUri: 'https://synapsis.test/callback', })).rejects.toMatchObject({ message: 'Connection expired', status: 401, code: 'invalid_grant', }); diff --git a/src/lib/stuffbox/client.ts b/src/lib/stuffbox/client.ts index ab49ce6..11a8339 100644 --- a/src/lib/stuffbox/client.ts +++ b/src/lib/stuffbox/client.ts @@ -97,17 +97,22 @@ export function configuredStuffboxUrl(): string | null { } 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 }> { +}): Promise<{ + id: string; + clientId: string; + callbackUrl: 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, + registration_mode: 'self_hosted', callback_url: input.callbackUrl, code_challenge: input.codeChallenge, code_challenge_method: 'S256', @@ -117,12 +122,15 @@ export async function createConnectionRequest(baseUrl: string, input: { })); return { id: string(data, 'id', 'request_id'), + clientId: string(data, 'client_id'), + callbackUrl: string(data, 'callback_url'), authorizationUrl: string(data, 'authorizationUrl', 'authorization_url'), expiresAt: string(data, 'expiresAt', 'expires_at'), }; } export async function exchangeAuthorizationCode(baseUrl: string, input: { + clientId: string; code: string; codeVerifier: string; redirectUri: string; @@ -132,6 +140,7 @@ export async function exchangeAuthorizationCode(baseUrl: string, input: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', + client_id: input.clientId, code: input.code, code_verifier: input.codeVerifier, redirect_uri: input.redirectUri, diff --git a/src/lib/stuffbox/connection-state.ts b/src/lib/stuffbox/connection-state.ts index 3f597bd..4f05ef7 100644 --- a/src/lib/stuffbox/connection-state.ts +++ b/src/lib/stuffbox/connection-state.ts @@ -7,9 +7,10 @@ const CONTEXT = 'stuffbox-connection-state'; export interface StuffboxConnectionState { userId: string; baseUrl: string; + clientId: string; verifier: string; state: string; - redirectUri: string; + callbackUrl: string; expiresAt: number; } @@ -30,8 +31,20 @@ export async function consumeStuffboxConnectionState(): Promise Date.now() ? value : null; + const value = JSON.parse(openStuffboxSecret(token, CONTEXT)) as Partial; + if ( + typeof value.userId !== 'string' + || typeof value.baseUrl !== 'string' + || typeof value.clientId !== 'string' + || typeof value.verifier !== 'string' + || typeof value.state !== 'string' + || typeof value.callbackUrl !== 'string' + || typeof value.expiresAt !== 'number' + || value.expiresAt <= Date.now() + ) { + return null; + } + return value as StuffboxConnectionState; } catch { return null; }