Auto-scan X-rated pics and fail-closed blur on map, profiles, and uploads

This commit is contained in:
Ryan Salazar
2026-06-26 22:42:10 -04:00
parent 684de38ffd
commit d533cdc194
9 changed files with 437 additions and 34 deletions

View File

@@ -1,7 +1,9 @@
"use client";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { ScannedImage } from "@/components/ScannedImage";
import { profileMediaLikelyExplicit } from "@/lib/xpic-scanner";
import { useAccess } from "@/components/Providers";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { MessageCircle, MapPin } from "lucide-react";
@@ -17,6 +19,7 @@ interface CruiserCardProps {
}
export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) {
const { blurExplicit } = useAccess();
const name = profile.is_anonymous
? "Anonymous"
: profile.display_name || profile.username || "Cruiser";
@@ -27,12 +30,14 @@ export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCar
onClick={() => onView(profile)}
>
<div className={`relative aspect-square ${vanillaMode ? "vanilla-blur" : ""}`}>
<Image
<ScannedImage
src={profile.avatar_url || "https://placehold.co/200x200/1a1220/ff2d6b?text=?"}
alt={name}
fill
className="object-cover explicit-content"
sizes="200px"
flaggedExplicit={profileMediaLikelyExplicit(profile.avatar_url, profile.gallery)}
blurExplicit={blurExplicit || !!vanillaMode}
/>
{profile.status !== "offline" && (
<div className="absolute top-2 right-2">

View File

@@ -2,6 +2,9 @@
import { useState } from "react";
import Image from "next/image";
import { ScannedImage } from "@/components/ScannedImage";
import { useExplicitScan } from "@/hooks/useExplicitScan";
import { isExplicitForDisplay } from "@/lib/xpic-scanner";
import {
ArrowLeft,
Ban,
@@ -99,6 +102,21 @@ export function CruiserProfileView({
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const scanTargetUrl =
profile && open
? profile.gallery?.length
? profile.gallery[Math.min(activePhoto, profile.gallery.length - 1)]?.url
: profile.avatar_url
: null;
const scanTargetExplicit =
profile && open
? profile.gallery?.length
? (profile.gallery[Math.min(activePhoto, profile.gallery.length - 1)]?.is_explicit ??
true)
: true
: false;
const activeScan = useExplicitScan(scanTargetUrl, scanTargetExplicit);
if (!profile || !open) return null;
const name = profile.is_verified
@@ -111,7 +129,7 @@ export function CruiserProfileView({
const photos = profile.gallery?.length
? profile.gallery.slice(0, 5)
: profile.avatar_url
? [{ url: profile.avatar_url, is_explicit: false }]
? [{ url: profile.avatar_url, is_explicit: true }]
: [];
const mediaItems: ProfileMediaItem[] = photos.map((ph, i) => ({
@@ -126,8 +144,14 @@ export function CruiserProfileView({
created_at: new Date().toISOString(),
}));
const shouldBlurPhoto = (explicit: boolean) =>
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
const shouldBlurPhoto = (explicit: boolean, scan?: typeof activeScan) => {
const xExplicit = isExplicitForDisplay(explicit, scan ?? 'pending');
return (
vanillaMode ||
(blurExplicit && xExplicit) ||
(xExplicit && !capabilities.canViewExplicit)
);
};
const viewer = authProfile ?? guestViewerProfile();
const viewerId = user?.id ?? "guest";
@@ -209,7 +233,10 @@ export function CruiserProfileView({
type="button"
className={cn(
"relative w-full h-full block",
shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && "id-blur"
shouldBlurPhoto(
photos[activePhoto]?.is_explicit ?? true,
activeScan
) && "id-blur"
)}
onClick={() => openLightbox(activePhoto)}
>
@@ -222,7 +249,10 @@ export function CruiserProfileView({
unoptimized
/>
</button>
{shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && (
{shouldBlurPhoto(
photos[activePhoto]?.is_explicit ?? true,
activeScan
) && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 gap-2 px-6 text-center">
<Lock className="h-8 w-8 text-white" />
<p className="text-sm text-white font-medium">
@@ -253,11 +283,17 @@ export function CruiserProfileView({
}}
className={cn(
"relative w-11 h-11 sm:w-12 sm:h-12 rounded-lg overflow-hidden border-2 touch-manipulation",
activePhoto === i ? "border-primary shadow-pulse-glow" : "border-white/30",
shouldBlurPhoto(ph.is_explicit) && "id-blur"
activePhoto === i ? "border-primary shadow-pulse-glow" : "border-white/30"
)}
>
<Image src={ph.url} alt="" fill className="object-cover" unoptimized />
<ScannedImage
src={ph.url}
alt=""
fill
className="object-cover"
flaggedExplicit={ph.is_explicit}
blurExplicit={blurExplicit || !!vanillaMode || !capabilities.canViewExplicit}
/>
</button>
))}
</div>
@@ -424,17 +460,16 @@ export function CruiserProfileView({
key={`${ph.url}-${i}`}
type="button"
onClick={() => setActivePhoto(i)}
className={cn(
"relative aspect-square rounded overflow-hidden",
shouldBlurPhoto(ph.is_explicit) && "id-blur"
)}
className="relative aspect-square rounded overflow-hidden"
>
<Image src={ph.url} alt="" fill className="object-cover" unoptimized />
{shouldBlurPhoto(ph.is_explicit) && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Lock className="h-4 w-4 text-white" />
</div>
)}
<ScannedImage
src={ph.url}
alt=""
fill
className="object-cover"
flaggedExplicit={ph.is_explicit}
blurExplicit={blurExplicit || !!vanillaMode || !capabilities.canViewExplicit}
/>
</button>
))}
{capabilities.canUploadPhotos && (
@@ -509,7 +544,10 @@ export function CruiserProfileView({
setLightboxIndex(i);
setActivePhoto(i);
}}
blur={shouldBlurPhoto(mediaItems[lightboxIndex]?.is_explicit ?? false)}
blur={shouldBlurPhoto(
mediaItems[lightboxIndex]?.is_explicit ?? true,
activeScan
)}
/>
</div>
);

View File

@@ -1,9 +1,11 @@
"use client";
import Image from "next/image";
import type { Profile } from "@/types";
import { interestLabel } from "@/lib/interests";
import { formatDistance } from "@/lib/profile-stats";
import { ScannedImage } from "@/components/ScannedImage";
import { profileMediaLikelyExplicit } from "@/lib/xpic-scanner";
import { useAccess } from "@/components/Providers";
interface InterestPreviewRailProps {
profiles: Profile[];
@@ -16,6 +18,7 @@ export function InterestPreviewRail({
interests,
onSelect,
}: InterestPreviewRailProps) {
const { blurExplicit } = useAccess();
if (!interests.length || !profiles.length) return null;
return (
@@ -37,7 +40,14 @@ export function InterestPreviewRail({
className="shrink-0 w-[72px] touch-manipulation active:scale-95 transition-transform"
>
<div className="relative w-[72px] h-[72px] rounded-xl overflow-hidden border-2 border-primary/40">
<Image src={avatar} alt="" fill className="object-cover" unoptimized />
<ScannedImage
src={avatar}
alt=""
fill
className="object-cover"
flaggedExplicit={profileMediaLikelyExplicit(p.avatar_url, p.gallery)}
blurExplicit={blurExplicit}
/>
{p.status !== "offline" && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-400 ring-2 ring-black/50" />
)}

View File

@@ -21,6 +21,7 @@ import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useAuth } from "@/lib/auth/context";
import { usePrivacy } from "@/contexts/PrivacyContext";
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
import { profileMediaLikelyExplicit, scanUrlHintsExplicit } from "@/lib/xpic-scanner";
import { loadHideFromRules } from "@/lib/privacy-rules";
import { loadBlockList } from "@/lib/block-list";
import { distanceMiles } from "@/lib/profile-stats";
@@ -87,10 +88,21 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
useEffect(() => {
import("leaflet").then((L) => {
const shouldBlurAvatar = (p: Profile, blur: boolean) => {
if (!blur) return false;
if (profileMediaLikelyExplicit(p.avatar_url, p.gallery)) return true;
const url = p.avatar_url || "";
if (!url) return true;
const hint = scanUrlHintsExplicit(url);
if (!hint) return true;
return hint.isExplicit;
};
const createPhoto = (p: Profile, blur: boolean) => {
const url =
p.avatar_url ||
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
const avatarBlur = shouldBlurAvatar(p, blur);
const dist =
p.distance_miles ??
(p.lat && p.lng
@@ -98,7 +110,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
: 1);
const distLabel =
dist < 1 ? dist.toFixed(1) : String(Math.max(1, Math.round(dist)));
const blurStyle = blur ? "filter:blur(6px);" : "";
const blurStyle = avatarBlur ? "filter:blur(6px);" : "";
const safeUrl = url.replace(/'/g, "%27");
return L.divIcon({

View File

@@ -52,6 +52,7 @@ import { useAuth } from "@/lib/auth/context";
import { useAccess } from "@/components/Providers";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { cn } from "@/lib/utils";
import { ScannedImage } from "@/components/ScannedImage";
interface MediaGalleryManagerProps {
onVerifyClick?: () => void;
@@ -124,10 +125,14 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
};
const handleCropConfirm = async (file: File) => {
const { error } = await addPhoto(file, activeAlbum, user?.id);
const { error, scanReason } = await addPhoto(file, activeAlbum, user?.id);
if (error) setMessage(error);
else {
setMessage("Photo added!");
setMessage(
scanReason && scanReason !== 'looks safe'
? `Photo added — auto-scanned as X-rated (${scanReason}). Blurred for guests until verified.`
: 'Photo added — passed safety scan.'
);
await refresh();
}
};
@@ -201,6 +206,10 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
{photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid
</Badge>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
Uploads are auto-scanned for X-rated content flagged pics stay blurred for guests
and unverified viewers.
</p>
{blurExplicit && (
<button
type="button"
@@ -349,15 +358,16 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
className={cn(
"relative aspect-square rounded-lg overflow-hidden group border-2",
onProfile ? "border-primary/60" : "border-transparent",
blurExplicit && photo.is_explicit && "id-blur"
)}
>
<Image
<ScannedImage
src={photo.url}
alt=""
fill
className="object-cover explicit-content"
unoptimized={photo.url.startsWith("data:")}
flaggedExplicit={photo.is_explicit}
blurExplicit={blurExplicit}
showScanBadge={photo.is_explicit}
/>
<div className="absolute top-1 left-1 flex gap-0.5">
<button

View File

@@ -0,0 +1,48 @@
'use client';
import Image, { type ImageProps } from 'next/image';
import { useExplicitScan } from '@/hooks/useExplicitScan';
import { isExplicitForDisplay } from '@/lib/xpic-scanner';
import { cn } from '@/lib/utils';
interface ScannedImageProps extends Omit<ImageProps, 'src'> {
src: string;
flaggedExplicit?: boolean;
blurExplicit?: boolean;
showScanBadge?: boolean;
}
export function ScannedImage({
src,
flaggedExplicit = false,
blurExplicit = false,
showScanBadge = false,
className,
alt,
...rest
}: ScannedImageProps) {
const scan = useExplicitScan(src, flaggedExplicit);
const blur = blurExplicit && isExplicitForDisplay(flaggedExplicit, scan);
return (
<div className={cn('relative', rest.fill && 'h-full w-full')}>
<Image
src={src}
alt={alt ?? ''}
className={cn(className, blur && 'id-blur')}
unoptimized={src.startsWith('data:') || rest.unoptimized}
{...rest}
/>
{showScanBadge && blur && scan !== 'pending' && typeof scan !== 'string' && (
<span className="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[9px] text-amber-200">
X-rated · auto-blurred
</span>
)}
{showScanBadge && scan === 'pending' && blurExplicit && (
<span className="absolute inset-0 flex items-center justify-center bg-black/30 text-[10px] text-white/80">
Scanning
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useEffect, useState } from 'react';
import {
scanImageUrlForExplicit,
scanUrlHintsExplicit,
type XPicScanResult,
} from '@/lib/xpic-scanner';
export function useExplicitScan(
url: string | null | undefined,
flaggedExplicit = false
): XPicScanResult | 'pending' {
const [scan, setScan] = useState<XPicScanResult | 'pending'>(() => {
if (flaggedExplicit) {
return { isExplicit: true, confidence: 'high', reason: 'flagged' };
}
if (!url) {
return { isExplicit: false, confidence: 'high', reason: 'no url' };
}
const hint = scanUrlHintsExplicit(url);
if (hint?.confidence === 'high') return hint;
return 'pending';
});
useEffect(() => {
if (flaggedExplicit) {
setScan({ isExplicit: true, confidence: 'high', reason: 'flagged' });
return;
}
if (!url) {
setScan({ isExplicit: false, confidence: 'high', reason: 'no url' });
return;
}
const hint = scanUrlHintsExplicit(url);
if (hint?.confidence === 'high') {
setScan(hint);
return;
}
let cancelled = false;
setScan('pending');
scanImageUrlForExplicit(url).then((result) => {
if (!cancelled) setScan(result);
});
return () => {
cancelled = true;
};
}, [url, flaggedExplicit]);
return scan;
}

View File

@@ -1,5 +1,6 @@
import { createClient } from "@/lib/supabase/client";
import { optimizeImage, getVideoDuration } from "@/lib/image-optimize";
import { explicitFlagFromScan, scanImageFileForExplicit } from "@/lib/xpic-scanner";
import { countProfilePinned } from "@/lib/profile-media";
import {
MAX_ALBUMS_PER_USER,
@@ -205,8 +206,10 @@ export async function addPhoto(
file: File,
albumId: string | null,
userId?: string | null
): Promise<{ photo?: GalleryPhoto; error?: string }> {
): Promise<{ photo?: GalleryPhoto; error?: string; scanReason?: string }> {
const optimized = await optimizeImage(file);
const scan = await scanImageFileForExplicit(optimized);
const is_explicit = explicitFlagFromScan(scan);
if (!userId) {
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
@@ -219,13 +222,13 @@ export async function addPhoto(
id: uid(),
album_id: albumId,
url,
is_explicit: true,
is_explicit,
created_at: new Date().toISOString(),
},
photos.length
);
writeLocal(LS_PHOTOS, [...photos, photo]);
return { photo };
return { photo, scanReason: scan.reason };
}
const supabase = createClient();
@@ -260,7 +263,7 @@ export async function addPhoto(
user_id: userId,
album_id: albumId === "main" ? null : albumId,
url: publicUrl,
is_explicit: true,
is_explicit,
show_on_profile: showOnProfile,
profile_sort_order: showOnProfile ? pinned : 999,
sort_order: count ?? 0,
@@ -269,7 +272,10 @@ export async function addPhoto(
.single();
if (error) return { error: error.message };
return { photo: normalizePhoto(data as GalleryPhoto, count ?? 0) };
return {
photo: normalizePhoto(data as GalleryPhoto, count ?? 0),
scanReason: scan.reason,
};
}
export async function addVideo(

222
src/lib/xpic-scanner.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* Automatic X-rated / explicit image scanner.
* Fail-closed: ambiguous or unscanned images are treated as explicit.
*/
export type XPicConfidence = 'high' | 'medium' | 'low';
export interface XPicScanResult {
isExplicit: boolean;
confidence: XPicConfidence;
reason: string;
}
const CACHE_KEY = 'eg_xpic_scan_cache';
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const EXPLICIT_URL_RE =
/(?:explicit|x-?rated|xrated|nsfw|nude|naked|xxx|porn|onlyfans|fansly|dick|cock|bare|xpic)/i;
const SAFE_PLACEHOLDER_RE = /placehold\.co.*text=\?/i;
type ScanCache = Record<string, { result: XPicScanResult; at: number }>;
function readCache(): ScanCache {
if (typeof window === 'undefined') return {};
try {
const raw = localStorage.getItem(CACHE_KEY);
return raw ? (JSON.parse(raw) as ScanCache) : {};
} catch {
return {};
}
}
function writeCacheEntry(url: string, result: XPicScanResult) {
if (typeof window === 'undefined') return;
const cache = readCache();
cache[url] = { result, at: Date.now() };
const pruned: ScanCache = {};
for (const [k, v] of Object.entries(cache)) {
if (Date.now() - v.at < CACHE_TTL_MS) pruned[k] = v;
}
localStorage.setItem(CACHE_KEY, JSON.stringify(pruned));
}
function getCached(url: string): XPicScanResult | null {
const hit = readCache()[url];
if (!hit || Date.now() - hit.at > CACHE_TTL_MS) return null;
return hit.result;
}
/** Fast synchronous URL / filename heuristics. */
export function scanUrlHintsExplicit(url: string): XPicScanResult | null {
if (!url?.trim()) {
return { isExplicit: false, confidence: 'high', reason: 'empty' };
}
const lower = url.toLowerCase();
if (SAFE_PLACEHOLDER_RE.test(lower)) {
return { isExplicit: false, confidence: 'high', reason: 'placeholder' };
}
if (EXPLICIT_URL_RE.test(lower)) {
return { isExplicit: true, confidence: 'high', reason: 'url keyword' };
}
if (lower.includes('text=explicit') || lower.includes('text=nsfw')) {
return { isExplicit: true, confidence: 'high', reason: 'placeholder explicit' };
}
return null;
}
export function scanFilenameExplicit(filename: string): XPicScanResult | null {
if (!filename?.trim()) return null;
if (EXPLICIT_URL_RE.test(filename)) {
return { isExplicit: true, confidence: 'high', reason: 'filename keyword' };
}
return null;
}
function isFleshTone(r: number, g: number, b: number): boolean {
return (
r > 95 &&
g > 40 &&
b > 20 &&
r > g &&
r > b &&
Math.abs(r - g) > 15 &&
r - b > 15 &&
r < 250
);
}
async function analyzeBitmap(bitmap: ImageBitmap): Promise<XPicScanResult> {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
return { isExplicit: true, confidence: 'medium', reason: 'scan unavailable' };
}
const scale = size / Math.max(bitmap.width, bitmap.height);
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
canvas.width = w;
canvas.height = h;
ctx.drawImage(bitmap, 0, 0, w, h);
const { data } = ctx.getImageData(0, 0, w, h);
const marginX = Math.floor(w * 0.15);
const marginY = Math.floor(h * 0.1);
let flesh = 0;
let sampled = 0;
for (let y = marginY; y < h - marginY; y += 2) {
for (let x = marginX; x < w - marginX; x += 2) {
const i = (y * w + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (a < 128) continue;
sampled++;
if (isFleshTone(r, g, b)) flesh++;
}
}
bitmap.close();
if (sampled < 20) {
return { isExplicit: true, confidence: 'medium', reason: 'low sample' };
}
const ratio = flesh / sampled;
if (ratio >= 0.42) {
return { isExplicit: true, confidence: 'high', reason: 'skin-tone density' };
}
if (ratio >= 0.28) {
return { isExplicit: true, confidence: 'medium', reason: 'elevated skin-tone' };
}
return { isExplicit: false, confidence: 'high', reason: 'looks safe' };
}
export async function scanImageFileForExplicit(file: File): Promise<XPicScanResult> {
const nameHit = scanFilenameExplicit(file.name);
if (nameHit) return nameHit;
if (!file.type.startsWith('image/')) {
return { isExplicit: true, confidence: 'medium', reason: 'non-image upload' };
}
try {
const bitmap = await createImageBitmap(file);
return analyzeBitmap(bitmap);
} catch {
return { isExplicit: true, confidence: 'medium', reason: 'file read failed' };
}
}
export async function scanImageUrlForExplicit(url: string): Promise<XPicScanResult> {
const cached = getCached(url);
if (cached) return cached;
const hint = scanUrlHintsExplicit(url);
if (hint?.confidence === 'high') {
writeCacheEntry(url, hint);
return hint;
}
if (typeof window === 'undefined') {
return { isExplicit: true, confidence: 'medium', reason: 'server' };
}
try {
const img = new window.Image();
img.crossOrigin = 'anonymous';
const loaded = new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('load failed'));
});
img.src = url;
await loaded;
const bitmap = await createImageBitmap(img);
const result = await analyzeBitmap(bitmap);
writeCacheEntry(url, result);
return result;
} catch {
const fallback =
hint ?? ({ isExplicit: true, confidence: 'medium', reason: 'url load blocked' } as const);
writeCacheEntry(url, fallback);
return fallback;
}
}
/** Gallery DB flag + scanner — never downgrade explicit, fail-closed while pending. */
export function isExplicitForDisplay(
flaggedExplicit: boolean,
scan: XPicScanResult | 'pending' | null | undefined
): boolean {
if (flaggedExplicit) return true;
if (scan === 'pending' || scan == null) return true;
return scan.isExplicit;
}
export function explicitFlagFromScan(scan: XPicScanResult): boolean {
return scan.isExplicit || scan.confidence !== 'high';
}
/** Profile avatar / gallery quick check without async load. */
export function profileMediaLikelyExplicit(
avatarUrl: string | null | undefined,
gallery?: { url: string; is_explicit?: boolean }[]
): boolean {
if (gallery?.some((g) => g.is_explicit)) return true;
if (avatarUrl) {
const hint = scanUrlHintsExplicit(avatarUrl);
if (hint?.isExplicit) return true;
}
if (gallery) {
for (const g of gallery) {
const hint = scanUrlHintsExplicit(g.url);
if (hint?.isExplicit) return true;
}
}
return false;
}