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";
|
||||
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
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 { 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
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