Admins (the two pubkeys in src/lib/admins.ts) can now moderate any event: - Post authoritative Event Updates (incl. cancel/reschedule) on events they don't own, clearly labelled "Admin" and marked with an ["admin","true"] tag - Take down any event (already wired via DeleteEntityDialog + isAdminPubkey) SECURITY FIX: kind-7103 Event Updates are now only trusted from the event's host (pubkey in the `a` coordinate) or a SkateSpots admin. Previously ANY npub could publish a fake "CANCELLED" update — spoofing the detail-page banner and spamming RSVPers' notification bells. Enforced in both parseEventUpdate() and useActivityNotifications. Note: admins intentionally CANNOT open the edit form on others' events — an addressable event (31923) can only be replaced by the key that signed it; editing under a different key would fork it, not edit it. Moderation is done via authoritative updates + takedown instead. Docs: NIP.md updated with the host/admin trust rule and the admin marker tag. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
|
import {
|
|
EVENT_UPDATE_KIND,
|
|
parseEventUpdate,
|
|
type EventUpdate,
|
|
type EventUpdateKind,
|
|
type SkateEvent,
|
|
} from '@/lib/types';
|
|
import { eventCoord } from '@/hooks/useEventRsvps';
|
|
import { isAdminPubkey } from '@/lib/admins';
|
|
|
|
/**
|
|
* Host "Event Updates" (kind 7103) — a public broadcast pinned to a calendar
|
|
* event. Hosts use it to say "rain, moved to Sunday" / "meet at the back ledge".
|
|
* Everyone who RSVP'd is p-tagged so they get a notification; anyone browsing
|
|
* the event page also sees the update thread.
|
|
*/
|
|
|
|
export function useEventUpdates(coord: string | undefined) {
|
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
|
|
|
return useQuery({
|
|
queryKey: ['event-updates', coord, getQueryKeySuffix()],
|
|
enabled: !!coord,
|
|
staleTime: 30_000,
|
|
queryFn: async (c) => {
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
|
const events = await querySkateContent(
|
|
[{ kinds: [EVENT_UPDATE_KIND], '#a': [coord!], limit: 100 }],
|
|
{ signal },
|
|
);
|
|
|
|
const updates: EventUpdate[] = [];
|
|
for (const ev of events) {
|
|
const u = parseEventUpdate(ev);
|
|
if (u && u.eventCoord === coord) updates.push(u);
|
|
}
|
|
// Newest first.
|
|
return updates.sort((a, b) => b.createdAt - a.createdAt);
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Publish a host update. Only the event's own author should call this (the UI
|
|
* gates it). All `notifyPubkeys` (RSVPers) are p-tagged so they get pinged.
|
|
*/
|
|
export function usePostEventUpdate() {
|
|
const { mutateAsync: publish, isPending } = useNostrPublish();
|
|
const { user } = useCurrentUser();
|
|
const queryClient = useQueryClient();
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (opts: {
|
|
event: SkateEvent;
|
|
message: string;
|
|
updateKind?: EventUpdateKind;
|
|
/** RSVPer pubkeys to notify (deduped, minus the host). */
|
|
notifyPubkeys?: string[];
|
|
}) => {
|
|
if (!user) throw new Error('You must be logged in to post an update.');
|
|
const isHost = user.pubkey === opts.event.pubkey;
|
|
const isAdmin = isAdminPubkey(user.pubkey);
|
|
if (!isHost && !isAdmin) {
|
|
throw new Error('Only the event host or a SkateSpots admin can post updates.');
|
|
}
|
|
|
|
const coord = eventCoord(opts.event);
|
|
const kind = opts.updateKind ?? 'update';
|
|
|
|
const notify = Array.from(
|
|
new Set((opts.notifyPubkeys ?? []).filter((pk) => pk && pk !== user.pubkey)),
|
|
);
|
|
|
|
const tags: string[][] = [
|
|
['a', coord],
|
|
['e', opts.event.id],
|
|
['update-kind', kind],
|
|
['t', 'skateboarding'],
|
|
['alt', `Update to a skate event: ${opts.event.title}`],
|
|
// Mark moderator actions so clients can show "Admin" instead of "Host".
|
|
// (An admin posting on an event they don't own is a moderation action.)
|
|
...(!isHost && isAdmin ? [['admin', 'true']] : []),
|
|
...notify.map((pk) => ['p', pk]),
|
|
];
|
|
|
|
return publish({
|
|
kind: EVENT_UPDATE_KIND,
|
|
content: opts.message.trim(),
|
|
tags,
|
|
});
|
|
},
|
|
onSuccess: (_data, variables) => {
|
|
const coord = eventCoord(variables.event);
|
|
queryClient.invalidateQueries({ queryKey: ['event-updates', coord] });
|
|
},
|
|
});
|
|
|
|
return { ...mutation, isPending: isPending || mutation.isPending };
|
|
}
|