diff --git a/src/app/api/settings/account-nsfw/route.ts b/src/app/api/settings/account-nsfw/route.ts index e0cb3b6..be29515 100644 --- a/src/app/api/settings/account-nsfw/route.ts +++ b/src/app/api/settings/account-nsfw/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; -import { requireAuth } from '@/lib/auth'; +import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature'; import { z } from 'zod'; const updateSchema = z.object({ @@ -22,9 +22,12 @@ const updateSchema = z.object({ */ export async function POST(request: NextRequest) { try { - const user = await requireAuth(); - const body = await request.json(); - const { isNsfw } = updateSchema.parse(body); + const signedAction = await request.json(); + if (signedAction.action !== 'update_account_nsfw') { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const user = await requireSignedAction(signedAction); + const { isNsfw } = updateSchema.parse(signedAction.data); if (!db) { return NextResponse.json({ error: 'Database not available' }, { status: 500 }); @@ -45,9 +48,13 @@ export async function POST(request: NextRequest) { if (error instanceof z.ZodError) { return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); } + if (error instanceof SignedActionError) { + return NextResponse.json({ error: 'Your identity could not be verified. Please unlock it and try again.' }, { status: 403 }); + } if (error instanceof Error && error.message === 'Authentication required') { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } + console.error('Account NSFW settings update error:', error); return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 }); } } diff --git a/src/app/api/settings/nsfw/route.test.ts b/src/app/api/settings/nsfw/route.test.ts new file mode 100644 index 0000000..c858860 --- /dev/null +++ b/src/app/api/settings/nsfw/route.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { POST as updateViewingPreference } from './route'; +import { POST as updateAccountPreference } from '../account-nsfw/route'; +import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature'; +import { db } from '@/db'; + +vi.mock('@/lib/auth', () => ({ requireAuth: vi.fn() })); + +vi.mock('@/lib/auth/verify-signature', () => { + class MockSignedActionError extends Error {} + + return { + requireSignedAction: vi.fn(), + SignedActionError: MockSignedActionError, + }; +}); + +vi.mock('drizzle-orm', () => ({ eq: vi.fn(() => ({})) })); + +vi.mock('@/db', () => { + const where = vi.fn().mockResolvedValue(undefined); + const set = vi.fn(() => ({ where })); + const update = vi.fn(() => ({ set })); + + return { + db: { update }, + users: { id: 'id' }, + }; +}); + +const signedAction = (action: string, data: Record) => ({ + action, + data, + did: 'did:synapsis:test', + handle: 'tester', + ts: Date.now(), + nonce: 'nonce', + sig: 'signature', +}); + +const request = (body: unknown) => new Request('http://localhost/api/settings/nsfw', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), +}); + +describe('NSFW settings mutations', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSignedAction).mockResolvedValue({ + id: 'user-id', + ageVerifiedAt: new Date('2026-01-01T00:00:00.000Z'), + } as never); + }); + + it('accepts a signed viewing-preference update', async () => { + const payload = signedAction('update_nsfw_settings', { nsfwEnabled: true }); + const response = await updateViewingPreference(request(payload) as never); + + expect(response.status).toBe(200); + expect(requireSignedAction).toHaveBeenCalledWith(payload); + expect(db?.update).toHaveBeenCalled(); + }); + + it('accepts a signed account-level update', async () => { + const payload = signedAction('update_account_nsfw', { isNsfw: true }); + const response = await updateAccountPreference(request(payload) as never); + + expect(response.status).toBe(200); + expect(requireSignedAction).toHaveBeenCalledWith(payload); + expect(await response.json()).toMatchObject({ success: true, isNsfw: true }); + }); + + it('rejects the old unsigned request shape', async () => { + const response = await updateViewingPreference(request({ nsfwEnabled: true }) as never); + + expect(response.status).toBe(400); + expect(requireSignedAction).not.toHaveBeenCalled(); + }); + + it('returns a useful identity error when verification fails', async () => { + vi.mocked(requireSignedAction).mockRejectedValue(new SignedActionError('INVALID_SIGNATURE')); + const payload = signedAction('update_nsfw_settings', { nsfwEnabled: true }); + const response = await updateViewingPreference(request(payload) as never); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Your identity could not be verified. Please unlock it and try again.', + }); + }); +}); + diff --git a/src/app/api/settings/nsfw/route.ts b/src/app/api/settings/nsfw/route.ts index 58134fe..6e0ca6e 100644 --- a/src/app/api/settings/nsfw/route.ts +++ b/src/app/api/settings/nsfw/route.ts @@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, users } from '@/db'; import { eq } from 'drizzle-orm'; import { requireAuth } from '@/lib/auth'; // kept for GET -import { requireSignedAction } from '@/lib/auth/verify-signature'; +import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature'; import { z } from 'zod'; const updateSchema = z.object({ @@ -48,6 +48,9 @@ export async function GET() { export async function POST(request: NextRequest) { try { const signedAction = await request.json(); + if (signedAction.action !== 'update_nsfw_settings') { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } const user = await requireSignedAction(signedAction); // Trust signed payload data @@ -100,9 +103,13 @@ export async function POST(request: NextRequest) { if (error instanceof z.ZodError) { return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 }); } + if (error instanceof SignedActionError) { + return NextResponse.json({ error: 'Your identity could not be verified. Please unlock it and try again.' }, { status: 403 }); + } if (error instanceof Error && error.message === 'Authentication required') { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); } + console.error('NSFW settings update error:', error); return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 }); } } diff --git a/src/app/settings/content/page.tsx b/src/app/settings/content/page.tsx index ed6dae2..5bfa36e 100644 --- a/src/app/settings/content/page.tsx +++ b/src/app/settings/content/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { ArrowLeftIcon } from '@/components/Icons'; import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react'; +import { useAuth } from '@/lib/contexts/AuthContext'; interface NsfwSettings { nsfwEnabled: boolean; @@ -12,6 +13,7 @@ interface NsfwSettings { } export default function ContentSettingsPage() { + const { isIdentityUnlocked, signUserAction, setShowUnlockPrompt } = useAuth(); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -40,6 +42,12 @@ export default function ContentSettingsPage() { const handleToggleNsfw = async () => { if (!settings) return; + if (!isIdentityUnlocked) { + setError('Unlock your identity to change this setting.'); + setShowUnlockPrompt(true); + return; + } + // If enabling and not verified, show age modal if (!settings.nsfwEnabled && !settings.ageVerifiedAt) { setShowAgeModal(true); @@ -50,10 +58,13 @@ export default function ContentSettingsPage() { setSaving(true); setError(null); try { + const signedPayload = await signUserAction('update_nsfw_settings', { + nsfwEnabled: !settings.nsfwEnabled, + }); const res = await fetch('/api/settings/nsfw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nsfwEnabled: !settings.nsfwEnabled }), + body: JSON.stringify(signedPayload), }); if (res.ok) { @@ -65,21 +76,32 @@ export default function ContentSettingsPage() { const data = await res.json(); setError(data.error || 'Failed to update'); } - } catch { - setError('Failed to update settings'); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to update settings'); } finally { setSaving(false); } }; const handleAgeConfirm = async () => { + if (!isIdentityUnlocked) { + setShowAgeModal(false); + setError('Unlock your identity to change this setting.'); + setShowUnlockPrompt(true); + return; + } + setSaving(true); setError(null); try { + const signedPayload = await signUserAction('update_nsfw_settings', { + nsfwEnabled: true, + confirmAge: true, + }); const res = await fetch('/api/settings/nsfw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nsfwEnabled: true, confirmAge: true }), + body: JSON.stringify(signedPayload), }); if (res.ok) { @@ -96,8 +118,8 @@ export default function ContentSettingsPage() { const data = await res.json(); setError(data.error || 'Failed to verify'); } - } catch { - setError('Failed to verify age'); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to verify age'); } finally { setSaving(false); } @@ -106,13 +128,22 @@ export default function ContentSettingsPage() { const handleToggleAccountNsfw = async () => { if (!settings) return; + if (!isIdentityUnlocked) { + setError('Unlock your identity to change this setting.'); + setShowUnlockPrompt(true); + return; + } + setSaving(true); setError(null); try { + const signedPayload = await signUserAction('update_account_nsfw', { + isNsfw: !settings.isNsfw, + }); const res = await fetch('/api/settings/account-nsfw', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isNsfw: !settings.isNsfw }), + body: JSON.stringify(signedPayload), }); if (res.ok) { @@ -124,8 +155,8 @@ export default function ContentSettingsPage() { const data = await res.json(); setError(data.error || 'Failed to update'); } - } catch { - setError('Failed to update settings'); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to update settings'); } finally { setSaving(false); } diff --git a/src/lib/auth/verify-signature.ts b/src/lib/auth/verify-signature.ts index b9ffbbd..9c35bec 100644 --- a/src/lib/auth/verify-signature.ts +++ b/src/lib/auth/verify-signature.ts @@ -29,6 +29,13 @@ export interface SignedAction { sig: string; } +export class SignedActionError extends Error { + constructor(message: string) { + super(message); + this.name = 'SignedActionError'; + } +} + /** * Verify a signed action against a specific public key */ @@ -146,7 +153,7 @@ export async function requireSignedAction(signedAction: SignedAction): Promise