Fix NSFW settings by signing client mutations and handling locked identities
Hop-State: A_06FP8RGGWKVFCFAHNJBQWT0 Hop-Proposal: R_06FP8RG166W8E071QG1PYWG Hop-Task: T_06FP8QVS84N3BR04V6F3GJG Hop-Attempt: AT_06FP8QVS87X77VCAYKNHKF0
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth';
|
import { requireSignedAction, SignedActionError } from '@/lib/auth/verify-signature';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
@@ -22,9 +22,12 @@ const updateSchema = z.object({
|
|||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth();
|
const signedAction = await request.json();
|
||||||
const body = await request.json();
|
if (signedAction.action !== 'update_account_nsfw') {
|
||||||
const { isNsfw } = updateSchema.parse(body);
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const user = await requireSignedAction(signedAction);
|
||||||
|
const { isNsfw } = updateSchema.parse(signedAction.data);
|
||||||
|
|
||||||
if (!db) {
|
if (!db) {
|
||||||
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
return NextResponse.json({ error: 'Database not available' }, { status: 500 });
|
||||||
@@ -45,9 +48,13 @@ export async function POST(request: NextRequest) {
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
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') {
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
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 });
|
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>) => ({
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { db, users } from '@/db';
|
import { db, users } from '@/db';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth } from '@/lib/auth'; // kept for GET
|
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';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
@@ -48,6 +48,9 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const signedAction = await request.json();
|
const signedAction = await request.json();
|
||||||
|
if (signedAction.action !== 'update_nsfw_settings') {
|
||||||
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
||||||
|
}
|
||||||
const user = await requireSignedAction(signedAction);
|
const user = await requireSignedAction(signedAction);
|
||||||
|
|
||||||
// Trust signed payload data
|
// Trust signed payload data
|
||||||
@@ -100,9 +103,13 @@ export async function POST(request: NextRequest) {
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
return NextResponse.json({ error: 'Invalid input', details: error.issues }, { status: 400 });
|
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') {
|
if (error instanceof Error && error.message === 'Authentication required') {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
|
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 });
|
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@/components/Icons';
|
import { ArrowLeftIcon } from '@/components/Icons';
|
||||||
import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react';
|
import { Eye, EyeOff, AlertTriangle, Check } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
|
||||||
interface NsfwSettings {
|
interface NsfwSettings {
|
||||||
nsfwEnabled: boolean;
|
nsfwEnabled: boolean;
|
||||||
@@ -12,6 +13,7 @@ interface NsfwSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContentSettingsPage() {
|
export default function ContentSettingsPage() {
|
||||||
|
const { isIdentityUnlocked, signUserAction, setShowUnlockPrompt } = useAuth();
|
||||||
const [settings, setSettings] = useState<NsfwSettings | null>(null);
|
const [settings, setSettings] = useState<NsfwSettings | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -40,6 +42,12 @@ export default function ContentSettingsPage() {
|
|||||||
const handleToggleNsfw = async () => {
|
const handleToggleNsfw = async () => {
|
||||||
if (!settings) return;
|
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 enabling and not verified, show age modal
|
||||||
if (!settings.nsfwEnabled && !settings.ageVerifiedAt) {
|
if (!settings.nsfwEnabled && !settings.ageVerifiedAt) {
|
||||||
setShowAgeModal(true);
|
setShowAgeModal(true);
|
||||||
@@ -50,10 +58,13 @@ export default function ContentSettingsPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const signedPayload = await signUserAction('update_nsfw_settings', {
|
||||||
|
nsfwEnabled: !settings.nsfwEnabled,
|
||||||
|
});
|
||||||
const res = await fetch('/api/settings/nsfw', {
|
const res = await fetch('/api/settings/nsfw', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ nsfwEnabled: !settings.nsfwEnabled }),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -65,21 +76,32 @@ export default function ContentSettingsPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error || 'Failed to update');
|
setError(data.error || 'Failed to update');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
setError('Failed to update settings');
|
setError(error instanceof Error ? error.message : 'Failed to update settings');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAgeConfirm = async () => {
|
const handleAgeConfirm = async () => {
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setShowAgeModal(false);
|
||||||
|
setError('Unlock your identity to change this setting.');
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const signedPayload = await signUserAction('update_nsfw_settings', {
|
||||||
|
nsfwEnabled: true,
|
||||||
|
confirmAge: true,
|
||||||
|
});
|
||||||
const res = await fetch('/api/settings/nsfw', {
|
const res = await fetch('/api/settings/nsfw', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ nsfwEnabled: true, confirmAge: true }),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -96,8 +118,8 @@ export default function ContentSettingsPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error || 'Failed to verify');
|
setError(data.error || 'Failed to verify');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
setError('Failed to verify age');
|
setError(error instanceof Error ? error.message : 'Failed to verify age');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -106,13 +128,22 @@ export default function ContentSettingsPage() {
|
|||||||
const handleToggleAccountNsfw = async () => {
|
const handleToggleAccountNsfw = async () => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
|
|
||||||
|
if (!isIdentityUnlocked) {
|
||||||
|
setError('Unlock your identity to change this setting.');
|
||||||
|
setShowUnlockPrompt(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
const signedPayload = await signUserAction('update_account_nsfw', {
|
||||||
|
isNsfw: !settings.isNsfw,
|
||||||
|
});
|
||||||
const res = await fetch('/api/settings/account-nsfw', {
|
const res = await fetch('/api/settings/account-nsfw', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ isNsfw: !settings.isNsfw }),
|
body: JSON.stringify(signedPayload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -124,8 +155,8 @@ export default function ContentSettingsPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setError(data.error || 'Failed to update');
|
setError(data.error || 'Failed to update');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
setError('Failed to update settings');
|
setError(error instanceof Error ? error.message : 'Failed to update settings');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export interface SignedAction {
|
|||||||
sig: string;
|
sig: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SignedActionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SignedActionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify a signed action against a specific public key
|
* Verify a signed action against a specific public key
|
||||||
*/
|
*/
|
||||||
@@ -146,7 +153,7 @@ export async function requireSignedAction(signedAction: SignedAction): Promise<t
|
|||||||
const result = await verifyUserAction(signedAction);
|
const result = await verifyUserAction(signedAction);
|
||||||
|
|
||||||
if (!result.valid) {
|
if (!result.valid) {
|
||||||
throw new Error(result.error || 'Invalid signature');
|
throw new SignedActionError(result.error || 'Invalid signature');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.user!;
|
return result.user!;
|
||||||
|
|||||||
Reference in New Issue
Block a user