Blur NSFW profile avatars and banners unless the viewer has enabled NSFW

Hop-State: A_06FP9DH64R0TWDEQST5Z460
Hop-Proposal: R_06FP9DF1AKH8Z9GA7AD24G0
Hop-Task: T_06FP9B65YW8Z4VM9386KDB8
Hop-Attempt: AT_06FP9B65YZ94YY9QS6C6Q38
This commit is contained in:
2026-07-15 00:38:06 -07:00
committed by Hop
parent c17b27b240
commit b291c5a453
23 changed files with 206 additions and 34 deletions
+2
View File
@@ -40,6 +40,8 @@ export async function GET() {
did: session.user.did, did: session.user.did,
publicKey: session.user.publicKey, publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted, privateKeyEncrypted: session.user.privateKeyEncrypted,
isNsfw: session.user.isNsfw,
nsfwEnabled: session.user.nsfwEnabled,
}, },
accounts, accounts,
}); });
+17 -1
View File
@@ -1,7 +1,23 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/db';
export async function GET() { export async function GET() {
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821';
let isNsfw = false;
try {
let node = await db.query.nodes.findFirst({ where: { domain } });
if (!node) {
const nodes = await db.query.nodes.findMany({ limit: 2 });
if (nodes.length === 1) node = nodes[0];
}
isNsfw = node?.isNsfw === true;
} catch (error) {
console.error('Runtime config lookup failed:', error);
}
return NextResponse.json({ return NextResponse.json({
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821', domain,
isNsfw,
}); });
} }
+3 -1
View File
@@ -34,13 +34,14 @@ export async function GET() {
.map(e => e.trim().toLowerCase()) .map(e => e.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
let admins: { handle: string; displayName: string | null; avatarUrl: string | null }[] = []; let admins: { handle: string; displayName: string | null; avatarUrl: string | null; isNsfw: boolean }[] = [];
if (adminEmails.length > 0) { if (adminEmails.length > 0) {
const adminUsers = await db const adminUsers = await db
.select({ .select({
handle: users.handle, handle: users.handle,
displayName: users.displayName, displayName: users.displayName,
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
isNsfw: users.isNsfw,
}) })
.from(users) .from(users)
.where(inArray(users.email, adminEmails)); .where(inArray(users.email, adminEmails));
@@ -56,6 +57,7 @@ export async function GET() {
publicKey, publicKey,
admins, admins,
turnstileSiteKey: null, turnstileSiteKey: null,
isNsfw: false,
}); });
} }
+2
View File
@@ -746,6 +746,8 @@ export async function GET(request: Request) {
avatarUrl: sp.author.avatarUrl, avatarUrl: sp.author.avatarUrl,
isSwarm: true, isSwarm: true,
isBot: sp.author.isBot, isBot: sp.author.isBot,
isNsfw: sp.author.isNsfw,
nodeIsNsfw: sp.nodeIsNsfw,
nodeDomain: sp.nodeDomain, nodeDomain: sp.nodeDomain,
}, },
media: sp.media?.map((m, idx) => ({ media: sp.media?.map((m, idx) => ({
+6
View File
@@ -33,6 +33,8 @@ type SearchUser = {
profileUrl?: string | null; profileUrl?: string | null;
isRemote?: boolean; isRemote?: boolean;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
}; };
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => { const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
@@ -102,6 +104,7 @@ export async function GET(request: Request) {
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
bio: users.bio, bio: users.bio,
isBot: users.isBot, isBot: users.isBot,
isNsfw: users.isNsfw,
}) })
.from(users) .from(users)
.where(and( .where(and(
@@ -133,6 +136,7 @@ export async function GET(request: Request) {
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
bio: users.bio, bio: users.bio,
isBot: users.isBot, isBot: users.isBot,
isNsfw: users.isNsfw,
}) })
.from(users) .from(users)
.where(userConditions) .where(userConditions)
@@ -168,6 +172,8 @@ export async function GET(request: Request) {
profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`, profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`,
isRemote: true, isRemote: true,
isBot: profileData.profile.isBot, isBot: profileData.profile.isBot,
isNsfw: profileData.profile.isNsfw,
nodeIsNsfw: profileData.profile.nodeIsNsfw,
}; };
if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) { if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
searchUsers = [remoteUser, ...searchUsers].slice(0, limit); searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
+23 -5
View File
@@ -21,6 +21,8 @@ export interface SwarmUserProfile {
postsCount: number; postsCount: number;
createdAt: string; createdAt: string;
isBot?: boolean; isBot?: boolean;
isNsfw: boolean;
nodeIsNsfw: boolean;
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain") botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
nodeDomain: string; nodeDomain: string;
publicKey?: string; // Signing key for verifying actions publicKey?: string; // Signing key for verifying actions
@@ -42,6 +44,8 @@ export interface SwarmUserPost {
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
nodeDomain?: string; nodeDomain?: string;
}; };
media?: { url: string; mimeType?: string; altText?: string }[]; media?: { url: string; mimeType?: string; altText?: string }[];
@@ -81,7 +85,7 @@ function parseMediaJson(mediaJson: string | null) {
} }
} }
function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost { function mapLocalPostToSwarmPost(post: any, nodeDomain: string, nodeIsNsfw: boolean): SwarmUserPost {
return { return {
id: post.id, id: post.id,
originalPostId: post.id, originalPostId: post.id,
@@ -97,6 +101,8 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
displayName: post.author.displayName || post.author.handle, displayName: post.author.displayName || post.author.handle,
avatarUrl: post.author.avatarUrl || undefined, avatarUrl: post.author.avatarUrl || undefined,
isBot: post.author.isBot || undefined, isBot: post.author.isBot || undefined,
isNsfw: post.author.isNsfw,
nodeIsNsfw,
nodeDomain, nodeDomain,
} : undefined, } : undefined,
media: (post.media || []).map((item: any) => ({ media: (post.media || []).map((item: any) => ({
@@ -112,14 +118,15 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined, linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson), linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
repostOfId: post.repostOfId || undefined, repostOfId: post.repostOfId || undefined,
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined, repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain, nodeIsNsfw) : undefined,
}; };
} }
function mapUserSwarmRepostToSwarmPost( function mapUserSwarmRepostToSwarmPost(
row: typeof userSwarmReposts.$inferSelect, row: typeof userSwarmReposts.$inferSelect,
author: typeof users.$inferSelect, author: typeof users.$inferSelect,
nodeDomain: string nodeDomain: string,
nodeIsNsfw: boolean
): SwarmUserPost { ): SwarmUserPost {
return { return {
id: row.id, id: row.id,
@@ -136,6 +143,8 @@ function mapUserSwarmRepostToSwarmPost(
displayName: author.displayName || author.handle, displayName: author.displayName || author.handle,
avatarUrl: author.avatarUrl || undefined, avatarUrl: author.avatarUrl || undefined,
isBot: author.isBot || undefined, isBot: author.isBot || undefined,
isNsfw: author.isNsfw,
nodeIsNsfw,
nodeDomain, nodeDomain,
}, },
repostOfId: row.originalPostId, repostOfId: row.originalPostId,
@@ -153,6 +162,7 @@ function mapUserSwarmRepostToSwarmPost(
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`, handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
displayName: row.authorDisplayName || row.authorHandle, displayName: row.authorDisplayName || row.authorHandle,
avatarUrl: row.authorAvatarUrl || undefined, avatarUrl: row.authorAvatarUrl || undefined,
nodeIsNsfw: false,
nodeDomain: row.nodeDomain, nodeDomain: row.nodeDomain,
}, },
media: parseMediaJson(row.mediaJson), media: parseMediaJson(row.mediaJson),
@@ -185,6 +195,12 @@ export async function GET(request: NextRequest, context: RouteContext) {
} }
const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost'; const nodeDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost';
let node = await db.query.nodes.findFirst({ where: { domain: nodeDomain } });
if (!node) {
const localNodes = await db.query.nodes.findMany({ limit: 2 });
if (localNodes.length === 1) node = localNodes[0];
}
const nodeIsNsfw = node?.isNsfw === true;
// Find the user // Find the user
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
@@ -215,6 +231,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
postsCount: user.postsCount, postsCount: user.postsCount,
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
isBot: user.isBot || undefined, isBot: user.isBot || undefined,
isNsfw: user.isNsfw,
nodeIsNsfw,
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined, botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
nodeDomain, nodeDomain,
publicKey: user.publicKey, // Expose signing key publicKey: user.publicKey, // Expose signing key
@@ -235,8 +253,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
}); });
const swarmPosts: SwarmUserPost[] = [ const swarmPosts: SwarmUserPost[] = [
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)), ...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain, nodeIsNsfw)),
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)), ...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain, nodeIsNsfw)),
] ]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit); .slice(0, limit);
+3
View File
@@ -96,6 +96,8 @@ export async function GET(request: Request, context: RouteContext) {
avatarUrl: null, avatarUrl: null,
} : null, } : null,
did: profile.did, did: profile.did,
isNsfw: profile.isNsfw,
nodeIsNsfw: profile.nodeIsNsfw,
} }
}); });
} }
@@ -131,6 +133,7 @@ export async function GET(request: Request, context: RouteContext) {
publicKey: user.publicKey, // Signing key publicKey: user.publicKey, // Signing key
did: user.did, // V2 Identity did: user.did, // V2 Identity
dmPrivacy: user.dmPrivacy, dmPrivacy: user.dmPrivacy,
isNsfw: user.isNsfw,
}; };
// Check if viewer can DM this user // Check if viewer can DM this user
+1
View File
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
avatarUrl: users.avatarUrl, avatarUrl: users.avatarUrl,
createdAt: users.createdAt, createdAt: users.createdAt,
isBot: users.isBot, isBot: users.isBot,
isNsfw: users.isNsfw,
}) })
.from(users) .from(users)
.where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`) .where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`)
+7 -1
View File
@@ -20,6 +20,8 @@ interface User {
profileUrl?: string | null; profileUrl?: string | null;
isRemote?: boolean; isRemote?: boolean;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
} }
function UserCard({ user }: { user: User }) { function UserCard({ user }: { user: User }) {
@@ -27,7 +29,7 @@ function UserCard({ user }: { user: User }) {
return ( return (
<Link href={`/u/${user.handle}`} className="user-card"> <Link href={`/u/${user.handle}`} className="user-card">
<div className="avatar"> <div className="avatar">
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} /> <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
</div> </div>
<div className="user-card-info"> <div className="user-card-info">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
@@ -66,8 +68,10 @@ interface SwarmPost {
handle: string; handle: string;
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
isNsfw: boolean;
}; };
nodeDomain: string; nodeDomain: string;
nodeIsNsfw: boolean;
likeCount: number; likeCount: number;
repostCount: number; repostCount: number;
replyCount: number; replyCount: number;
@@ -453,6 +457,8 @@ export default function ExplorePage() {
handle: post.author.handle, handle: post.author.handle,
displayName: post.author.displayName, displayName: post.author.displayName,
avatarUrl: post.author.avatarUrl, avatarUrl: post.author.avatarUrl,
isNsfw: post.author.isNsfw,
nodeIsNsfw: post.nodeIsNsfw,
}, },
media: post.media?.map((m, idx) => ({ media: post.media?.map((m, idx) => ({
id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`, id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
+3 -1
View File
@@ -20,6 +20,8 @@ interface User {
profileUrl?: string | null; profileUrl?: string | null;
isRemote?: boolean; isRemote?: boolean;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
} }
@@ -83,7 +85,7 @@ function UserCard({ user }: { user: User }) {
className="hover-bg" className="hover-bg"
> >
<div className="avatar"> <div className="avatar">
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} /> <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+11 -13
View File
@@ -15,6 +15,7 @@ import { useAuth } from '@/lib/contexts/AuthContext';
import { hasUnsavedChanges } from '@/lib/forms/dirty-state'; import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage'; import { AvatarImage } from '@/components/AvatarImage';
import { ProfileBanner } from '@/components/ProfileBanner';
interface BotOwner { interface BotOwner {
id: string; id: string;
@@ -30,6 +31,8 @@ interface UserSummary {
bio?: string | null; bio?: string | null;
avatarUrl?: string | null; avatarUrl?: string | null;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
} }
type ProfileMediaField = 'avatarUrl' | 'headerUrl'; type ProfileMediaField = 'avatarUrl' | 'headerUrl';
@@ -45,7 +48,7 @@ function UserRow({ user }: { user: UserSummary }) {
return ( return (
<Link href={`/u/${user.handle}`} className="user-row"> <Link href={`/u/${user.handle}`} className="user-row">
<div className="avatar"> <div className="avatar">
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} /> <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
</div> </div>
<div className="user-row-content"> <div className="user-row-content">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
@@ -579,17 +582,12 @@ export default function ProfilePage() {
<div style={{ borderBottom: '1px solid var(--border)' }}> <div style={{ borderBottom: '1px solid var(--border)' }}>
{/* Banner */} {/* Banner */}
{/* Banner */} {/* Banner */}
<div <ProfileBanner
style={{ url={isEditing ? profileForm.headerUrl : user.headerUrl}
width: '100%', isNsfw={user.isNsfw}
aspectRatio: '3 / 1', nodeIsNsfw={user.nodeIsNsfw}
background: (isEditing ? profileForm.headerUrl : user.headerUrl) aspectRatio="3 / 1"
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover` />
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
position: 'relative',
}}
>
</div>
{/* Avatar & Actions */} {/* Avatar & Actions */}
<div style={{ padding: '0 16px' }}> <div style={{ padding: '0 16px' }}>
@@ -609,7 +607,7 @@ export default function ProfilePage() {
position: 'relative', position: 'relative',
}} }}
> >
<AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} /> <AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} nodeIsNsfw={user.nodeIsNsfw} alt={user.displayName || user.handle} />
</div> </div>
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
+18 -1
View File
@@ -1,6 +1,9 @@
'use client'; 'use client';
import { useState, type ImgHTMLAttributes } from 'react'; import { useState, type ImgHTMLAttributes } from 'react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useRuntimeConfig } from '@/lib/contexts/ConfigContext';
import { shouldBlurProfileMedia } from '@/lib/nsfw/profile-media';
export function getDiceBearAvatarUrl(seed: string): string { export function getDiceBearAvatarUrl(seed: string): string {
return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(seed)}`; return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(seed)}`;
@@ -9,18 +12,32 @@ export function getDiceBearAvatarUrl(seed: string): string {
interface AvatarImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> { interface AvatarImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
avatarUrl?: string | null; avatarUrl?: string | null;
seed: string; seed: string;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
} }
export function AvatarImage({ avatarUrl, seed, alt = '', onError, ...props }: AvatarImageProps) { export function AvatarImage({ avatarUrl, seed, isNsfw = false, nodeIsNsfw, alt = '', onError, style, ...props }: AvatarImageProps) {
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null); const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
const { user } = useAuth();
const { config } = useRuntimeConfig();
const customAvatar = avatarUrl?.trim(); const customAvatar = avatarUrl?.trim();
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed); const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
const blurred = shouldBlurProfileMedia({
accountIsNsfw: isNsfw,
nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false,
viewer: user,
});
return ( return (
<img <img
{...props} {...props}
src={src} src={src}
alt={alt} alt={alt}
style={{
...style,
filter: blurred ? 'blur(12px)' : style?.filter,
transform: blurred ? 'scale(1.12)' : style?.transform,
}}
onError={(event) => { onError={(event) => {
onError?.(event); onError?.(event);
if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar); if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar);
+2 -2
View File
@@ -640,7 +640,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<div className="post-header"> <div className="post-header">
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar"> <div className="avatar">
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} /> <AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} isNsfw={post.author.isNsfw} nodeIsNsfw={post.author.nodeIsNsfw} alt={post.author.displayName || post.author.handle} />
</div> </div>
</Link> </Link>
<div className="post-author"> <div className="post-author">
@@ -718,7 +718,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<div className="post-header"> <div className="post-header">
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar"> <div className="avatar">
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} /> <AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} isNsfw={post.author.isNsfw} nodeIsNsfw={post.author.nodeIsNsfw} alt={post.author.displayName || post.author.handle} />
</div> </div>
</Link> </Link>
<div className="post-author"> <div className="post-author">
+46
View File
@@ -0,0 +1,46 @@
'use client';
import { useAuth } from '@/lib/contexts/AuthContext';
import { useRuntimeConfig } from '@/lib/contexts/ConfigContext';
import { shouldBlurProfileMedia } from '@/lib/nsfw/profile-media';
export function ProfileBanner({
url,
isNsfw = false,
nodeIsNsfw,
height,
aspectRatio,
borderBottom,
}: {
url?: string | null;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
height?: string | number;
aspectRatio?: string;
borderBottom?: string;
}) {
const { user } = useAuth();
const { config } = useRuntimeConfig();
const blurred = shouldBlurProfileMedia({
accountIsNsfw: isNsfw,
nodeIsNsfw: nodeIsNsfw ?? config?.isNsfw ?? false,
viewer: user,
});
return (
<div style={{ width: '100%', height, aspectRatio, overflow: 'hidden', borderBottom, position: 'relative' }}>
<div
aria-label={blurred ? 'Sensitive profile banner hidden' : undefined}
style={{
position: 'absolute',
inset: 0,
background: url
? `url(${url}) center/cover no-repeat`
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
filter: blurred ? 'blur(20px)' : undefined,
transform: blurred ? 'scale(1.12)' : undefined,
}}
/>
</div>
);
}
+10 -7
View File
@@ -3,11 +3,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { AvatarImage } from './AvatarImage'; import { AvatarImage } from './AvatarImage';
import { ProfileBanner } from './ProfileBanner';
interface Admin { interface Admin {
handle: string; handle: string;
displayName: string | null; displayName: string | null;
avatarUrl: string | null; avatarUrl: string | null;
isNsfw: boolean;
} }
interface NodeInfo { interface NodeInfo {
@@ -17,6 +19,7 @@ interface NodeInfo {
rules: string; rules: string;
bannerUrl: string; bannerUrl: string;
admins: Admin[]; admins: Admin[];
isNsfw: boolean;
} }
export function RightSidebar() { export function RightSidebar() {
@@ -28,6 +31,7 @@ export function RightSidebar() {
rules: '', rules: '',
bannerUrl: '', bannerUrl: '',
admins: [] as Admin[], admins: [] as Admin[],
isNsfw: false,
}); });
const [version, setVersion] = useState<{ const [version, setVersion] = useState<{
version: string; version: string;
@@ -109,12 +113,11 @@ export function RightSidebar() {
<aside className="aside"> <aside className="aside">
<div className="card" style={{ overflow: 'hidden', padding: 0 }}> <div className="card" style={{ overflow: 'hidden', padding: 0 }}>
{nodeInfo.bannerUrl && ( {nodeInfo.bannerUrl && (
<div <ProfileBanner
style={{ url={nodeInfo.bannerUrl}
height: '140px', nodeIsNsfw={nodeInfo.isNsfw}
background: `url(${nodeInfo.bannerUrl}) center/cover no-repeat`, height={140}
borderBottom: '1px solid var(--border)', borderBottom="1px solid var(--border)"
}}
/> />
)} )}
@@ -175,7 +178,7 @@ export function RightSidebar() {
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
<AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} alt={admin.displayName || admin.handle} /> <AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} isNsfw={admin.isNsfw} nodeIsNsfw={nodeInfo.isNsfw} alt={admin.displayName || admin.handle} />
</div> </div>
<div> <div>
<div style={{ fontWeight: 500, fontSize: '14px' }}> <div style={{ fontWeight: 500, fontSize: '14px' }}>
+2 -2
View File
@@ -341,7 +341,7 @@ export function Sidebar() {
}} }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
<AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} alt={account.displayName || account.handle} /> <AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} isNsfw={account.isNsfw} alt={account.displayName || account.handle} />
</div> </div>
<div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}> <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}>
<div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -428,7 +428,7 @@ export function Sidebar() {
}} }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName} /> <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} isNsfw={user.isNsfw} alt={user.displayName} />
</div> </div>
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}> <div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div> <div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
+4
View File
@@ -30,6 +30,8 @@ export interface AuthAccount {
publicKey: string; publicKey: string;
privateKeyEncrypted: string | null; privateKeyEncrypted: string | null;
email: string | null; email: string | null;
isNsfw: boolean;
nsfwEnabled: boolean;
isActive: boolean; isActive: boolean;
} }
@@ -111,6 +113,8 @@ function toAuthAccount(session: SessionRecord, activeToken: string | null): Auth
publicKey: session.user.publicKey, publicKey: session.user.publicKey,
privateKeyEncrypted: session.user.privateKeyEncrypted, privateKeyEncrypted: session.user.privateKeyEncrypted,
email: session.user.email, email: session.user.email,
isNsfw: session.user.isNsfw,
nsfwEnabled: session.user.nsfwEnabled,
isActive: session.token === activeToken, isActive: session.token === activeToken,
}; };
} }
+2
View File
@@ -12,6 +12,8 @@ export interface User {
did?: string; did?: string;
publicKey?: string; publicKey?: string;
privateKeyEncrypted?: string; privateKeyEncrypted?: string;
isNsfw?: boolean;
nsfwEnabled?: boolean;
} }
export interface AuthAccount extends User { export interface AuthAccount extends User {
+3
View File
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react
interface RuntimeConfig { interface RuntimeConfig {
domain: string; domain: string;
isNsfw: boolean;
} }
interface ConfigContextType { interface ConfigContextType {
@@ -27,12 +28,14 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
.then((data) => { .then((data) => {
setConfig({ setConfig({
domain: data.domain || 'localhost:43821', domain: data.domain || 'localhost:43821',
isNsfw: data.isNsfw === true,
}); });
}) })
.catch(() => { .catch(() => {
// Fallback to build-time value if fetch fails // Fallback to build-time value if fetch fails
setConfig({ setConfig({
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821', domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821',
isNsfw: false,
}); });
}) })
.finally(() => { .finally(() => {
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { shouldBlurProfileMedia } from './profile-media';
describe('shouldBlurProfileMedia', () => {
it('blurs sensitive account media for signed-out viewers', () => {
expect(shouldBlurProfileMedia({ accountIsNsfw: true, viewer: null })).toBe(true);
});
it('blurs every account from an NSFW node when the viewer has NSFW disabled', () => {
expect(shouldBlurProfileMedia({ nodeIsNsfw: true, viewer: { nsfwEnabled: false } })).toBe(true);
});
it('shows sensitive profile media when the viewer has enabled NSFW', () => {
expect(shouldBlurProfileMedia({ accountIsNsfw: true, nodeIsNsfw: true, viewer: { nsfwEnabled: true } })).toBe(false);
});
it('does not blur safe profile media', () => {
expect(shouldBlurProfileMedia({ viewer: null })).toBe(false);
});
});
+15
View File
@@ -0,0 +1,15 @@
export interface ProfileMediaViewer {
nsfwEnabled?: boolean;
}
export function shouldBlurProfileMedia({
accountIsNsfw = false,
nodeIsNsfw = false,
viewer,
}: {
accountIsNsfw?: boolean;
nodeIsNsfw?: boolean;
viewer: ProfileMediaViewer | null;
}): boolean {
return (accountIsNsfw || nodeIsNsfw) && viewer?.nsfwEnabled !== true;
}
+4
View File
@@ -343,6 +343,8 @@ export interface SwarmUserProfile {
postsCount: number; postsCount: number;
createdAt: string; createdAt: string;
isBot?: boolean; isBot?: boolean;
isNsfw: boolean;
nodeIsNsfw: boolean;
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain") botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
nodeDomain: string; nodeDomain: string;
chatPublicKey?: string; chatPublicKey?: string;
@@ -365,6 +367,8 @@ export interface SwarmUserPost {
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
nodeDomain?: string; nodeDomain?: string;
}; };
media?: { url: string; mimeType?: string; altText?: string }[]; media?: { url: string; mimeType?: string; altText?: string }[];
+2
View File
@@ -14,6 +14,8 @@ export interface User {
isRemote?: boolean; isRemote?: boolean;
profileUrl?: string | null; profileUrl?: string | null;
isBot?: boolean; isBot?: boolean;
isNsfw?: boolean;
nodeIsNsfw?: boolean;
isSwarm?: boolean; // Whether this user is from a Synapsis swarm node isSwarm?: boolean; // Whether this user is from a Synapsis swarm node
nodeDomain?: string | null; // Domain of the node this user is from (for swarm users) nodeDomain?: string | null; // Domain of the node this user is from (for swarm users)
did?: string; did?: string;