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:
@@ -40,6 +40,8 @@ export async function GET() {
|
||||
did: session.user.did,
|
||||
publicKey: session.user.publicKey,
|
||||
privateKeyEncrypted: session.user.privateKeyEncrypted,
|
||||
isNsfw: session.user.isNsfw,
|
||||
nsfwEnabled: session.user.nsfwEnabled,
|
||||
},
|
||||
accounts,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
|
||||
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({
|
||||
domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || process.env.NODE_DOMAIN || 'localhost:43821',
|
||||
domain,
|
||||
isNsfw,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,13 +34,14 @@ export async function GET() {
|
||||
.map(e => e.trim().toLowerCase())
|
||||
.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) {
|
||||
const adminUsers = await db
|
||||
.select({
|
||||
handle: users.handle,
|
||||
displayName: users.displayName,
|
||||
avatarUrl: users.avatarUrl,
|
||||
isNsfw: users.isNsfw,
|
||||
})
|
||||
.from(users)
|
||||
.where(inArray(users.email, adminEmails));
|
||||
@@ -56,6 +57,7 @@ export async function GET() {
|
||||
publicKey,
|
||||
admins,
|
||||
turnstileSiteKey: null,
|
||||
isNsfw: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -746,6 +746,8 @@ export async function GET(request: Request) {
|
||||
avatarUrl: sp.author.avatarUrl,
|
||||
isSwarm: true,
|
||||
isBot: sp.author.isBot,
|
||||
isNsfw: sp.author.isNsfw,
|
||||
nodeIsNsfw: sp.nodeIsNsfw,
|
||||
nodeDomain: sp.nodeDomain,
|
||||
},
|
||||
media: sp.media?.map((m, idx) => ({
|
||||
|
||||
@@ -33,6 +33,8 @@ type SearchUser = {
|
||||
profileUrl?: string | null;
|
||||
isRemote?: boolean;
|
||||
isBot?: boolean;
|
||||
isNsfw?: boolean;
|
||||
nodeIsNsfw?: boolean;
|
||||
};
|
||||
|
||||
const parseRemoteHandleQuery = (query: string): { handle: string; domain: string } | null => {
|
||||
@@ -102,6 +104,7 @@ export async function GET(request: Request) {
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
isBot: users.isBot,
|
||||
isNsfw: users.isNsfw,
|
||||
})
|
||||
.from(users)
|
||||
.where(and(
|
||||
@@ -133,6 +136,7 @@ export async function GET(request: Request) {
|
||||
avatarUrl: users.avatarUrl,
|
||||
bio: users.bio,
|
||||
isBot: users.isBot,
|
||||
isNsfw: users.isNsfw,
|
||||
})
|
||||
.from(users)
|
||||
.where(userConditions)
|
||||
@@ -168,6 +172,8 @@ export async function GET(request: Request) {
|
||||
profileUrl: `https://${parsedRemote.domain}/@${parsedRemote.handle}`,
|
||||
isRemote: true,
|
||||
isBot: profileData.profile.isBot,
|
||||
isNsfw: profileData.profile.isNsfw,
|
||||
nodeIsNsfw: profileData.profile.nodeIsNsfw,
|
||||
};
|
||||
if (!searchUsers.some((user) => user.handle.toLowerCase() === remoteUser.handle.toLowerCase())) {
|
||||
searchUsers = [remoteUser, ...searchUsers].slice(0, limit);
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface SwarmUserProfile {
|
||||
postsCount: number;
|
||||
createdAt: string;
|
||||
isBot?: boolean;
|
||||
isNsfw: boolean;
|
||||
nodeIsNsfw: boolean;
|
||||
botOwnerHandle?: string; // Handle of the bot's owner (e.g., "user" or "user@domain")
|
||||
nodeDomain: string;
|
||||
publicKey?: string; // Signing key for verifying actions
|
||||
@@ -42,6 +44,8 @@ export interface SwarmUserPost {
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
isBot?: boolean;
|
||||
isNsfw?: boolean;
|
||||
nodeIsNsfw?: boolean;
|
||||
nodeDomain?: 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 {
|
||||
id: post.id,
|
||||
originalPostId: post.id,
|
||||
@@ -97,6 +101,8 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
|
||||
displayName: post.author.displayName || post.author.handle,
|
||||
avatarUrl: post.author.avatarUrl || undefined,
|
||||
isBot: post.author.isBot || undefined,
|
||||
isNsfw: post.author.isNsfw,
|
||||
nodeIsNsfw,
|
||||
nodeDomain,
|
||||
} : undefined,
|
||||
media: (post.media || []).map((item: any) => ({
|
||||
@@ -112,14 +118,15 @@ function mapLocalPostToSwarmPost(post: any, nodeDomain: string): SwarmUserPost {
|
||||
linkPreviewVideoUrl: post.linkPreviewVideoUrl || undefined,
|
||||
linkPreviewMedia: parseLinkPreviewMediaJson(post.linkPreviewMediaJson),
|
||||
repostOfId: post.repostOfId || undefined,
|
||||
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain) : undefined,
|
||||
repostOf: post.repostOf ? mapLocalPostToSwarmPost(post.repostOf, nodeDomain, nodeIsNsfw) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserSwarmRepostToSwarmPost(
|
||||
row: typeof userSwarmReposts.$inferSelect,
|
||||
author: typeof users.$inferSelect,
|
||||
nodeDomain: string
|
||||
nodeDomain: string,
|
||||
nodeIsNsfw: boolean
|
||||
): SwarmUserPost {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -136,6 +143,8 @@ function mapUserSwarmRepostToSwarmPost(
|
||||
displayName: author.displayName || author.handle,
|
||||
avatarUrl: author.avatarUrl || undefined,
|
||||
isBot: author.isBot || undefined,
|
||||
isNsfw: author.isNsfw,
|
||||
nodeIsNsfw,
|
||||
nodeDomain,
|
||||
},
|
||||
repostOfId: row.originalPostId,
|
||||
@@ -153,6 +162,7 @@ function mapUserSwarmRepostToSwarmPost(
|
||||
handle: row.authorHandle.includes('@') ? row.authorHandle : `${row.authorHandle}@${row.nodeDomain}`,
|
||||
displayName: row.authorDisplayName || row.authorHandle,
|
||||
avatarUrl: row.authorAvatarUrl || undefined,
|
||||
nodeIsNsfw: false,
|
||||
nodeDomain: row.nodeDomain,
|
||||
},
|
||||
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';
|
||||
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
|
||||
const user = await db.query.users.findFirst({
|
||||
@@ -215,6 +231,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
postsCount: user.postsCount,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
isBot: user.isBot || undefined,
|
||||
isNsfw: user.isNsfw,
|
||||
nodeIsNsfw,
|
||||
botOwnerHandle: user.isBot && user.botOwner ? user.botOwner.handle : undefined,
|
||||
nodeDomain,
|
||||
publicKey: user.publicKey, // Expose signing key
|
||||
@@ -235,8 +253,8 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
||||
});
|
||||
|
||||
const swarmPosts: SwarmUserPost[] = [
|
||||
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain)),
|
||||
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain)),
|
||||
...localPosts.map((post) => mapLocalPostToSwarmPost(post, nodeDomain, nodeIsNsfw)),
|
||||
...remoteRepostRows.map((row) => mapUserSwarmRepostToSwarmPost(row, user, nodeDomain, nodeIsNsfw)),
|
||||
]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, limit);
|
||||
|
||||
@@ -96,6 +96,8 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
avatarUrl: null,
|
||||
} : null,
|
||||
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
|
||||
did: user.did, // V2 Identity
|
||||
dmPrivacy: user.dmPrivacy,
|
||||
isNsfw: user.isNsfw,
|
||||
};
|
||||
|
||||
// Check if viewer can DM this user
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
avatarUrl: users.avatarUrl,
|
||||
createdAt: users.createdAt,
|
||||
isBot: users.isBot,
|
||||
isNsfw: users.isNsfw,
|
||||
})
|
||||
.from(users)
|
||||
.where(sql`${users.isSuspended} IS FALSE AND ${users.handle} NOT LIKE '%@%'`)
|
||||
|
||||
@@ -20,6 +20,8 @@ interface User {
|
||||
profileUrl?: string | null;
|
||||
isRemote?: boolean;
|
||||
isBot?: boolean;
|
||||
isNsfw?: boolean;
|
||||
nodeIsNsfw?: boolean;
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
@@ -27,7 +29,7 @@ function UserCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-card">
|
||||
<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 className="user-card-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
@@ -66,8 +68,10 @@ interface SwarmPost {
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
isNsfw: boolean;
|
||||
};
|
||||
nodeDomain: string;
|
||||
nodeIsNsfw: boolean;
|
||||
likeCount: number;
|
||||
repostCount: number;
|
||||
replyCount: number;
|
||||
@@ -453,6 +457,8 @@ export default function ExplorePage() {
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
avatarUrl: post.author.avatarUrl,
|
||||
isNsfw: post.author.isNsfw,
|
||||
nodeIsNsfw: post.nodeIsNsfw,
|
||||
},
|
||||
media: post.media?.map((m, idx) => ({
|
||||
id: `swarm:${post.nodeDomain}:${post.id}:media:${idx}`,
|
||||
|
||||
@@ -20,6 +20,8 @@ interface User {
|
||||
profileUrl?: string | null;
|
||||
isRemote?: boolean;
|
||||
isBot?: boolean;
|
||||
isNsfw?: boolean;
|
||||
nodeIsNsfw?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +85,7 @@ function UserCard({ user }: { user: User }) {
|
||||
className="hover-bg"
|
||||
>
|
||||
<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 style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
+11
-13
@@ -15,6 +15,7 @@ import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { hasUnsavedChanges } from '@/lib/forms/dirty-state';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
import { ProfileBanner } from '@/components/ProfileBanner';
|
||||
|
||||
interface BotOwner {
|
||||
id: string;
|
||||
@@ -30,6 +31,8 @@ interface UserSummary {
|
||||
bio?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
isBot?: boolean;
|
||||
isNsfw?: boolean;
|
||||
nodeIsNsfw?: boolean;
|
||||
}
|
||||
|
||||
type ProfileMediaField = 'avatarUrl' | 'headerUrl';
|
||||
@@ -45,7 +48,7 @@ function UserRow({ user }: { user: UserSummary }) {
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-row">
|
||||
<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 className="user-row-content">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
@@ -579,17 +582,12 @@ export default function ProfilePage() {
|
||||
<div style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
{/* Banner */}
|
||||
{/* Banner */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '3 / 1',
|
||||
background: (isEditing ? profileForm.headerUrl : user.headerUrl)
|
||||
? `url(${isEditing ? profileForm.headerUrl : user.headerUrl}) center/cover`
|
||||
: 'linear-gradient(135deg, var(--accent-muted) 0%, var(--background-tertiary) 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
<ProfileBanner
|
||||
url={isEditing ? profileForm.headerUrl : user.headerUrl}
|
||||
isNsfw={user.isNsfw}
|
||||
nodeIsNsfw={user.nodeIsNsfw}
|
||||
aspectRatio="3 / 1"
|
||||
/>
|
||||
|
||||
{/* Avatar & Actions */}
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
@@ -609,7 +607,7 @@ export default function ProfilePage() {
|
||||
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 style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
|
||||
Reference in New Issue
Block a user