Extend admin takedown to Lab profiles and gear/marketplace listings
Completes the admin moderation coverage from the previous commit. Lab profiles (coaches, filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers, musicians): - useLabProfiles / useLabProfile and useCoaches / useCoach now filter out admin-hidden entries (list) and 404 hidden ones (detail) via the shared moderation layer. Added filterAdminHiddenMixed() for the mixed-kind Lab union (kind read from each item's event). - CoachDetailPage (the shared Lab detail page) now shows the delete/takedown card to admins too, not just the owner, with admin-specific copy. DeleteEntityDialog already handles the owner-or-admin logic. Gear / marketplace listings (NIP-99, kind 30402): - useMarketplaceItems / useMarketplaceAtSpot filter out admin-hidden listings. - GearSwapItemCard: admins get a "Remove (admin)" action in the card menu with a confirmation dialog that routes through useDeleteEntity (NIP-09 delete + hidden label). Report stays for non-owners. SECURITY.md: updated the admin-moderation coverage list and auditor file map. Build + lint pass, no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
13eece3a1e
commit
82a74e01e0
7 changed files with 161 additions and 23 deletions
15
SECURITY.md
15
SECURITY.md
|
|
@ -216,11 +216,14 @@ intentionally *not* author-filtered.
|
|||
### Admin moderation / takedowns
|
||||
|
||||
Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
|
||||
down any listing (spot, crew, tour, event, shop, …). A takedown publishes both
|
||||
a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation
|
||||
label authored by the admin. The client-side layer (`useAdminModeration`) then
|
||||
hides that content across the app, so takedowns are reliable even when a relay
|
||||
won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose
|
||||
down any listing — spots, crews, tours, events, shops, Lab profiles (coaches,
|
||||
filmers, editors, photographers, builders, artists, graffiti, tattoo, trainers,
|
||||
musicians), and gear/marketplace listings. A takedown publishes both a NIP-09
|
||||
kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation label
|
||||
authored by the admin. The client-side layer (`useAdminModeration`, wired into
|
||||
every list/detail hook via React Query `select`) then hides that content across
|
||||
the app, so takedowns are reliable even when a relay won't honor a cross-author
|
||||
NIP-09 request. **Trust boundary:** only labels whose
|
||||
`pubkey` is in the hardcoded admin list are honored — this is re-verified
|
||||
client-side in `useAdminModeration` and is never derived from a `d` tag or any
|
||||
attacker-controllable value. Auditors should confirm the admin list is the only
|
||||
|
|
@ -264,7 +267,7 @@ oversights:
|
|||
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
||||
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
||||
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
|
||||
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` |
|
||||
| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts`, `src/components/DeleteEntityDialog.tsx`, `src/components/GearSwapItemCard.tsx` |
|
||||
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
||||
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
||||
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getSkateboardRating, getTrustLevel, SELLER_TRUST_BADGES } from '@/lib/skateboardRating';
|
||||
import { GEAR_CONDITIONS, GEAR_CATEGORIES, PRICE_TYPES, formatPrice, type MarketplaceItem } from '@/lib/types';
|
||||
|
|
@ -21,6 +24,7 @@ import {
|
|||
AlertTriangle,
|
||||
MoreVertical,
|
||||
Flag,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -28,6 +32,16 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
|
|
@ -42,13 +56,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
|||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(item.pubkey);
|
||||
const { mutateAsync: deleteEntity, isPending: isRemoving } = useDeleteEntity();
|
||||
const { toast } = useToast();
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(item.pubkey);
|
||||
const avatar = metadata?.picture;
|
||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
|
||||
const npub = nip19.npubEncode(item.pubkey);
|
||||
const isOwnListingCheck = user?.pubkey === item.pubkey;
|
||||
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||
|
||||
const handleAdminRemove = async () => {
|
||||
try {
|
||||
await deleteEntity({
|
||||
event: item.event,
|
||||
reason: `SkateSpots admin removed listing "${item.title}"`,
|
||||
});
|
||||
toast({
|
||||
title: 'Listing removed',
|
||||
description: 'This listing has been taken down and will be hidden across SkateSpots.',
|
||||
});
|
||||
setShowRemoveDialog(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Remove failed',
|
||||
description: error instanceof Error ? error.message : 'Something went wrong',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Format price display
|
||||
const priceDisplay = formatPrice(item.price, item.priceType);
|
||||
|
|
@ -139,8 +177,9 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
|||
</Badge>
|
||||
)}
|
||||
|
||||
{/* More options menu */}
|
||||
{!isOwnListingCheck && (
|
||||
{/* More options menu — shown to non-owners (Report) and to admins
|
||||
(admin takedown). Owners manage their own listing elsewhere. */}
|
||||
{(!isOwnListingCheck || isAdmin) && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -153,17 +192,31 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
|||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowReportDialog(true);
|
||||
}}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{!isOwnListingCheck && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowReportDialog(true);
|
||||
}}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
Report
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRemoveDialog(true);
|
||||
}}
|
||||
className="text-red-500 focus:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Remove (admin)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
@ -291,6 +344,37 @@ export function GearSwapItemCard({ item, trustScore, salesCount, isFollowing }:
|
|||
targetEventId={item.id}
|
||||
targetTitle={item.title}
|
||||
/>
|
||||
|
||||
{/* Admin takedown confirmation */}
|
||||
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||
<AlertDialogContent
|
||||
className="bg-black border-2 border-red-500/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-red-500" />
|
||||
Remove this listing? (admin)
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-gray-300">
|
||||
As a SkateSpots admin you're taking down "{item.title}". It will be hidden across the
|
||||
app for everyone and a deletion request will be sent to relays.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-gray-600" disabled={isRemoving}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleAdminRemove}
|
||||
disabled={isRemoving}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
{isRemoving ? 'Removing…' : 'Remove Listing'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,29 @@ export function filterAdminHidden<
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `filterAdminHidden`, but for a mixed-kind list where each item carries
|
||||
* its own source `event` (e.g. Lab profiles, which span 10 different kinds).
|
||||
* The kind is read from `it.event.kind`.
|
||||
*/
|
||||
export function filterAdminHiddenMixed<
|
||||
T extends {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
event: { kind: number };
|
||||
},
|
||||
>(items: T[], hidden: AdminHiddenSet): T[] {
|
||||
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
|
||||
return items.filter(
|
||||
(it) =>
|
||||
!hidden.has({
|
||||
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`,
|
||||
id: it.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAdminModeration() {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_COACH_KIND, parseSkateCoach, type SkateCoach } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
export function useCoaches(limit = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['coaches', limit, getQueryKeySuffix()],
|
||||
select: (coaches: SkateCoach[]) => filterAdminHidden(coaches, SKATE_COACH_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
@ -42,9 +45,14 @@ export function useCoaches(limit = 100) {
|
|||
|
||||
export function useCoach(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['coach', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (coach: SkateCoach | null) =>
|
||||
coach && hidden.has({ coord: `${SKATE_COACH_KIND}:${coach.pubkey}:${coach.identifier}`, id: coach.id })
|
||||
? null
|
||||
: coach,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@/lib/types';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration';
|
||||
|
||||
export type LabProfileType =
|
||||
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
|
||||
|
|
@ -82,9 +83,11 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
|
|||
|
||||
export function useLabProfiles(limit = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
|
||||
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
|
||||
|
|
@ -120,9 +123,17 @@ export function useLabProfiles(limit = 100) {
|
|||
|
||||
export function useLabProfile(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['lab-profile', identifier, pubkey, getQueryKeySuffix()],
|
||||
select: (profile: LabProfile | null) =>
|
||||
profile && hidden.has({
|
||||
coord: `${profile.event.kind}:${profile.pubkey}:${profile.identifier}`,
|
||||
id: profile.id,
|
||||
})
|
||||
? null
|
||||
: profile,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { MARKETPLACE_ITEM_KIND, parseMarketplaceItem, type MarketplaceItem } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||
|
|
@ -8,9 +9,11 @@ const TIMEOUT = 8000;
|
|||
|
||||
export function useMarketplaceItems(limit = 100) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
|
||||
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(TIMEOUT);
|
||||
|
||||
|
|
@ -41,9 +44,11 @@ export function useMarketplaceItems(limit = 100) {
|
|||
|
||||
export function useMarketplaceAtSpot(spotPubkey: string, spotIdentifier: string, limit = 20) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['marketplace-at-spot', spotPubkey, spotIdentifier, getQueryKeySuffix()],
|
||||
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(TIMEOUT);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useGigs } from '@/hooks/useGigs';
|
|||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
||||
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
||||
|
|
@ -575,17 +576,20 @@ export default function CoachDetailPage() {
|
|||
accentBg={`${accent.bg} hover:opacity-90`}
|
||||
/>
|
||||
|
||||
{/* Owner controls — delete your own Lab profile */}
|
||||
{user && user.pubkey === profile.pubkey && (
|
||||
{/* Owner controls — delete your own Lab profile; admins can take
|
||||
down any profile (DeleteEntityDialog gates owner-or-admin). */}
|
||||
{user && (user.pubkey === profile.pubkey || isAdminPubkey(user.pubkey)) && (
|
||||
<Card className="bg-black/90 border-2 border-red-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-red-400 uppercase font-black">
|
||||
Manage
|
||||
{user.pubkey === profile.pubkey ? 'Manage' : 'Admin'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-gray-400">
|
||||
Remove your {typeNoun} profile from The Lab.
|
||||
{user.pubkey === profile.pubkey
|
||||
? `Remove your ${typeNoun} profile from The Lab.`
|
||||
: `Take down this ${typeNoun} profile. It will be hidden across SkateSpots.`}
|
||||
</p>
|
||||
<DeleteEntityDialog
|
||||
event={profile.event}
|
||||
|
|
|
|||
Loading…
Reference in a new issue