Auto-scan X-rated pics and fail-closed blur on map, profiles, and uploads
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { MessageCircle, MapPin } from "lucide-react";
|
import { MessageCircle, MapPin } from "lucide-react";
|
||||||
@@ -17,6 +19,7 @@ interface CruiserCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) {
|
export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCardProps) {
|
||||||
|
const { blurExplicit } = useAccess();
|
||||||
const name = profile.is_anonymous
|
const name = profile.is_anonymous
|
||||||
? "Anonymous"
|
? "Anonymous"
|
||||||
: profile.display_name || profile.username || "Cruiser";
|
: profile.display_name || profile.username || "Cruiser";
|
||||||
@@ -27,12 +30,14 @@ export function CruiserCard({ profile, onChat, onView, vanillaMode }: CruiserCar
|
|||||||
onClick={() => onView(profile)}
|
onClick={() => onView(profile)}
|
||||||
>
|
>
|
||||||
<div className={`relative aspect-square ${vanillaMode ? "vanilla-blur" : ""}`}>
|
<div className={`relative aspect-square ${vanillaMode ? "vanilla-blur" : ""}`}>
|
||||||
<Image
|
<ScannedImage
|
||||||
src={profile.avatar_url || "https://placehold.co/200x200/1a1220/ff2d6b?text=?"}
|
src={profile.avatar_url || "https://placehold.co/200x200/1a1220/ff2d6b?text=?"}
|
||||||
alt={name}
|
alt={name}
|
||||||
fill
|
fill
|
||||||
className="object-cover explicit-content"
|
className="object-cover explicit-content"
|
||||||
sizes="200px"
|
sizes="200px"
|
||||||
|
flaggedExplicit={profileMediaLikelyExplicit(profile.avatar_url, profile.gallery)}
|
||||||
|
blurExplicit={blurExplicit || !!vanillaMode}
|
||||||
/>
|
/>
|
||||||
{profile.status !== "offline" && (
|
{profile.status !== "offline" && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { ScannedImage } from "@/components/ScannedImage";
|
||||||
|
import { useExplicitScan } from "@/hooks/useExplicitScan";
|
||||||
|
import { isExplicitForDisplay } from "@/lib/xpic-scanner";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Ban,
|
Ban,
|
||||||
@@ -99,6 +102,21 @@ export function CruiserProfileView({
|
|||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
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;
|
if (!profile || !open) return null;
|
||||||
|
|
||||||
const name = profile.is_verified
|
const name = profile.is_verified
|
||||||
@@ -111,7 +129,7 @@ export function CruiserProfileView({
|
|||||||
const photos = profile.gallery?.length
|
const photos = profile.gallery?.length
|
||||||
? profile.gallery.slice(0, 5)
|
? profile.gallery.slice(0, 5)
|
||||||
: profile.avatar_url
|
: profile.avatar_url
|
||||||
? [{ url: profile.avatar_url, is_explicit: false }]
|
? [{ url: profile.avatar_url, is_explicit: true }]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const mediaItems: ProfileMediaItem[] = photos.map((ph, i) => ({
|
const mediaItems: ProfileMediaItem[] = photos.map((ph, i) => ({
|
||||||
@@ -126,8 +144,14 @@ export function CruiserProfileView({
|
|||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const shouldBlurPhoto = (explicit: boolean) =>
|
const shouldBlurPhoto = (explicit: boolean, scan?: typeof activeScan) => {
|
||||||
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
|
const xExplicit = isExplicitForDisplay(explicit, scan ?? 'pending');
|
||||||
|
return (
|
||||||
|
vanillaMode ||
|
||||||
|
(blurExplicit && xExplicit) ||
|
||||||
|
(xExplicit && !capabilities.canViewExplicit)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const viewer = authProfile ?? guestViewerProfile();
|
const viewer = authProfile ?? guestViewerProfile();
|
||||||
const viewerId = user?.id ?? "guest";
|
const viewerId = user?.id ?? "guest";
|
||||||
@@ -209,7 +233,10 @@ export function CruiserProfileView({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full h-full block",
|
"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)}
|
onClick={() => openLightbox(activePhoto)}
|
||||||
>
|
>
|
||||||
@@ -222,7 +249,10 @@ export function CruiserProfileView({
|
|||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</button>
|
</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">
|
<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" />
|
<Lock className="h-8 w-8 text-white" />
|
||||||
<p className="text-sm text-white font-medium">
|
<p className="text-sm text-white font-medium">
|
||||||
@@ -253,11 +283,17 @@ export function CruiserProfileView({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-11 h-11 sm:w-12 sm:h-12 rounded-lg overflow-hidden border-2 touch-manipulation",
|
"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",
|
activePhoto === i ? "border-primary shadow-pulse-glow" : "border-white/30"
|
||||||
shouldBlurPhoto(ph.is_explicit) && "id-blur"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -424,17 +460,16 @@ export function CruiserProfileView({
|
|||||||
key={`${ph.url}-${i}`}
|
key={`${ph.url}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActivePhoto(i)}
|
onClick={() => setActivePhoto(i)}
|
||||||
className={cn(
|
className="relative aspect-square rounded overflow-hidden"
|
||||||
"relative aspect-square rounded overflow-hidden",
|
|
||||||
shouldBlurPhoto(ph.is_explicit) && "id-blur"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Image src={ph.url} alt="" fill className="object-cover" unoptimized />
|
<ScannedImage
|
||||||
{shouldBlurPhoto(ph.is_explicit) && (
|
src={ph.url}
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
alt=""
|
||||||
<Lock className="h-4 w-4 text-white" />
|
fill
|
||||||
</div>
|
className="object-cover"
|
||||||
)}
|
flaggedExplicit={ph.is_explicit}
|
||||||
|
blurExplicit={blurExplicit || !!vanillaMode || !capabilities.canViewExplicit}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{capabilities.canUploadPhotos && (
|
{capabilities.canUploadPhotos && (
|
||||||
@@ -509,7 +544,10 @@ export function CruiserProfileView({
|
|||||||
setLightboxIndex(i);
|
setLightboxIndex(i);
|
||||||
setActivePhoto(i);
|
setActivePhoto(i);
|
||||||
}}
|
}}
|
||||||
blur={shouldBlurPhoto(mediaItems[lightboxIndex]?.is_explicit ?? false)}
|
blur={shouldBlurPhoto(
|
||||||
|
mediaItems[lightboxIndex]?.is_explicit ?? true,
|
||||||
|
activeScan
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { Profile } from "@/types";
|
import type { Profile } from "@/types";
|
||||||
import { interestLabel } from "@/lib/interests";
|
import { interestLabel } from "@/lib/interests";
|
||||||
import { formatDistance } from "@/lib/profile-stats";
|
import { formatDistance } from "@/lib/profile-stats";
|
||||||
|
import { ScannedImage } from "@/components/ScannedImage";
|
||||||
|
import { profileMediaLikelyExplicit } from "@/lib/xpic-scanner";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
|
|
||||||
interface InterestPreviewRailProps {
|
interface InterestPreviewRailProps {
|
||||||
profiles: Profile[];
|
profiles: Profile[];
|
||||||
@@ -16,6 +18,7 @@ export function InterestPreviewRail({
|
|||||||
interests,
|
interests,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: InterestPreviewRailProps) {
|
}: InterestPreviewRailProps) {
|
||||||
|
const { blurExplicit } = useAccess();
|
||||||
if (!interests.length || !profiles.length) return null;
|
if (!interests.length || !profiles.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +40,14 @@ export function InterestPreviewRail({
|
|||||||
className="shrink-0 w-[72px] touch-manipulation active:scale-95 transition-transform"
|
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">
|
<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" && (
|
{p.status !== "offline" && (
|
||||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-400 ring-2 ring-black/50" />
|
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-emerald-400 ring-2 ring-black/50" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
|||||||
import { useAuth } from "@/lib/auth/context";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
import { usePrivacy } from "@/contexts/PrivacyContext";
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
|
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
|
||||||
|
import { profileMediaLikelyExplicit, scanUrlHintsExplicit } from "@/lib/xpic-scanner";
|
||||||
import { loadHideFromRules } from "@/lib/privacy-rules";
|
import { loadHideFromRules } from "@/lib/privacy-rules";
|
||||||
import { loadBlockList } from "@/lib/block-list";
|
import { loadBlockList } from "@/lib/block-list";
|
||||||
import { distanceMiles } from "@/lib/profile-stats";
|
import { distanceMiles } from "@/lib/profile-stats";
|
||||||
@@ -87,10 +88,21 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import("leaflet").then((L) => {
|
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 createPhoto = (p: Profile, blur: boolean) => {
|
||||||
const url =
|
const url =
|
||||||
p.avatar_url ||
|
p.avatar_url ||
|
||||||
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
|
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
|
||||||
|
const avatarBlur = shouldBlurAvatar(p, blur);
|
||||||
const dist =
|
const dist =
|
||||||
p.distance_miles ??
|
p.distance_miles ??
|
||||||
(p.lat && p.lng
|
(p.lat && p.lng
|
||||||
@@ -98,7 +110,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
: 1);
|
: 1);
|
||||||
const distLabel =
|
const distLabel =
|
||||||
dist < 1 ? dist.toFixed(1) : String(Math.max(1, Math.round(dist)));
|
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");
|
const safeUrl = url.replace(/'/g, "%27");
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { useAuth } from "@/lib/auth/context";
|
|||||||
import { useAccess } from "@/components/Providers";
|
import { useAccess } from "@/components/Providers";
|
||||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ScannedImage } from "@/components/ScannedImage";
|
||||||
|
|
||||||
interface MediaGalleryManagerProps {
|
interface MediaGalleryManagerProps {
|
||||||
onVerifyClick?: () => void;
|
onVerifyClick?: () => void;
|
||||||
@@ -124,10 +125,14 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCropConfirm = async (file: File) => {
|
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);
|
if (error) setMessage(error);
|
||||||
else {
|
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();
|
await refresh();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -201,6 +206,10 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
|
|||||||
{photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid
|
{photos.length}/{MAX_PHOTOS_PER_USER} · {videos.length}/{MAX_VIDEOS_PER_USER} vid
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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 && (
|
{blurExplicit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -349,15 +358,16 @@ export function MediaGalleryManager({ onVerifyClick }: MediaGalleryManagerProps)
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative aspect-square rounded-lg overflow-hidden group border-2",
|
"relative aspect-square rounded-lg overflow-hidden group border-2",
|
||||||
onProfile ? "border-primary/60" : "border-transparent",
|
onProfile ? "border-primary/60" : "border-transparent",
|
||||||
blurExplicit && photo.is_explicit && "id-blur"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Image
|
<ScannedImage
|
||||||
src={photo.url}
|
src={photo.url}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
className="object-cover explicit-content"
|
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">
|
<div className="absolute top-1 left-1 flex gap-0.5">
|
||||||
<button
|
<button
|
||||||
|
|||||||
48
src/components/ScannedImage.tsx
Normal file
48
src/components/ScannedImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/hooks/useExplicitScan.ts
Normal file
52
src/hooks/useExplicitScan.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { optimizeImage, getVideoDuration } from "@/lib/image-optimize";
|
import { optimizeImage, getVideoDuration } from "@/lib/image-optimize";
|
||||||
|
import { explicitFlagFromScan, scanImageFileForExplicit } from "@/lib/xpic-scanner";
|
||||||
import { countProfilePinned } from "@/lib/profile-media";
|
import { countProfilePinned } from "@/lib/profile-media";
|
||||||
import {
|
import {
|
||||||
MAX_ALBUMS_PER_USER,
|
MAX_ALBUMS_PER_USER,
|
||||||
@@ -205,8 +206,10 @@ export async function addPhoto(
|
|||||||
file: File,
|
file: File,
|
||||||
albumId: string | null,
|
albumId: string | null,
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
): Promise<{ photo?: GalleryPhoto; error?: string }> {
|
): Promise<{ photo?: GalleryPhoto; error?: string; scanReason?: string }> {
|
||||||
const optimized = await optimizeImage(file);
|
const optimized = await optimizeImage(file);
|
||||||
|
const scan = await scanImageFileForExplicit(optimized);
|
||||||
|
const is_explicit = explicitFlagFromScan(scan);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
|
const photos = readLocal<GalleryPhoto[]>(LS_PHOTOS, []);
|
||||||
@@ -219,13 +222,13 @@ export async function addPhoto(
|
|||||||
id: uid(),
|
id: uid(),
|
||||||
album_id: albumId,
|
album_id: albumId,
|
||||||
url,
|
url,
|
||||||
is_explicit: true,
|
is_explicit,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
photos.length
|
photos.length
|
||||||
);
|
);
|
||||||
writeLocal(LS_PHOTOS, [...photos, photo]);
|
writeLocal(LS_PHOTOS, [...photos, photo]);
|
||||||
return { photo };
|
return { photo, scanReason: scan.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -260,7 +263,7 @@ export async function addPhoto(
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
album_id: albumId === "main" ? null : albumId,
|
album_id: albumId === "main" ? null : albumId,
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
is_explicit: true,
|
is_explicit,
|
||||||
show_on_profile: showOnProfile,
|
show_on_profile: showOnProfile,
|
||||||
profile_sort_order: showOnProfile ? pinned : 999,
|
profile_sort_order: showOnProfile ? pinned : 999,
|
||||||
sort_order: count ?? 0,
|
sort_order: count ?? 0,
|
||||||
@@ -269,7 +272,10 @@ export async function addPhoto(
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (error) return { error: error.message };
|
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(
|
export async function addVideo(
|
||||||
|
|||||||
222
src/lib/xpic-scanner.ts
Normal file
222
src/lib/xpic-scanner.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user