diff --git a/apps/cursor/src/actions/rescan-own-plugin.ts b/apps/cursor/src/actions/rescan-own-plugin.ts new file mode 100644 index 00000000..888fcd49 --- /dev/null +++ b/apps/cursor/src/actions/rescan-own-plugin.ts @@ -0,0 +1,80 @@ +"use server"; + +import { z } from "zod"; +import { + ModerationError, + rescanPlugin, + webModerationContext, +} from "@/lib/plugins/moderation"; +import { pluginScanLimit } from "@/lib/rate-limit"; +import { createClient } from "@/utils/supabase/server"; +import { ActionError, authActionClient } from "./safe-action"; + +/** + * Scan states an owner may re-run themselves. `pending` and `scanning` are + * already in flight; `safe` has nothing to fix; `permanently_blocked` rows are + * excluded below regardless of status. + */ +const OWNER_RESCANNABLE = new Set(["flagged", "error", "unscanned"]); + +/** + * Owner-initiated re-scan. Same path as the admin "Rescan" (status reset, + * queue, audit event with the owner as actor), gated by ownership, the + * permanent block, and the per-user scan rate limit that already caps + * create/update scans. + */ +export const rescanOwnPluginAction = authActionClient + .metadata({ actionName: "rescan-own-plugin" }) + .schema(z.object({ pluginId: z.string().uuid() })) + .action(async ({ parsedInput: { pluginId }, ctx: { userId } }) => { + const supabase = await createClient(); + + const { data: existing, error: fetchError } = await supabase + .from("plugins") + .select("id, owner_id, scan_status, permanently_blocked") + .eq("id", pluginId) + .single(); + + if (fetchError || !existing) { + throw new ActionError("Plugin not found."); + } + + if (existing.owner_id !== userId) { + throw new ActionError( + "You do not have permission to update this plugin.", + ); + } + + if (existing.permanently_blocked) { + throw new ActionError( + "This plugin has been blocked and cannot be re-scanned. Contact support if you believe this is a mistake.", + ); + } + + if (!OWNER_RESCANNABLE.has(existing.scan_status)) { + throw new ActionError( + existing.scan_status === "safe" + ? "This plugin already passed its last scan." + : "A scan is already in progress.", + ); + } + + const { success } = await pluginScanLimit(userId); + if (!success) { + throw new ActionError( + "Too many scans in the last hour. Please try again later.", + ); + } + + try { + await rescanPlugin(webModerationContext(userId), pluginId); + } catch (err) { + throw new ActionError( + err instanceof ModerationError + ? err.message + : "Failed to start the scan.", + ); + } + + return { success: true }; + }); diff --git a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx index 0dfd589b..ee257c8f 100644 --- a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx +++ b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx @@ -7,6 +7,7 @@ import { Loader2, MoreHorizontal, Pencil, + RefreshCw, Trash2, } from "lucide-react"; import Link from "next/link"; @@ -16,6 +17,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { deletePluginAction } from "@/actions/delete-plugin"; import { requestPluginVerificationAction } from "@/actions/request-plugin-verification"; +import { rescanOwnPluginAction } from "@/actions/rescan-own-plugin"; import { togglePluginListingAction } from "@/actions/toggle-plugin-listing"; import { AlertDialog, @@ -106,6 +108,19 @@ export function PluginOwnerMenu({ plugin }: Props) { }, ); + const { execute: rescanPlugin, isExecuting: isRescanning } = useAction( + rescanOwnPluginAction, + { + onSuccess: () => { + toast.success("Scan queued. The listing updates when it finishes."); + router.refresh(); + }, + onError: ({ error }) => { + toast.error(error.serverError ?? "Failed to start the scan."); + }, + }, + ); + const { execute: deletePlugin, isExecuting: isDeleting } = useAction( deletePluginAction, { @@ -124,9 +139,15 @@ export function PluginOwnerMenu({ plugin }: Props) { return null; } - const busy = isRequesting || isToggling || isDeleting; + const busy = isRequesting || isToggling || isRescanning || isDeleting; const canVerify = !plugin.verified && !requestPending; const canPublish = !active && !plugin.permanently_blocked; + // Mirrors the server-side gate in rescanOwnPluginAction. + const canRescan = + !plugin.permanently_blocked && + (plugin.scan_status === "flagged" || + plugin.scan_status === "error" || + plugin.scan_status === "unscanned"); return ( <> @@ -192,6 +213,17 @@ export function PluginOwnerMenu({ plugin }: Props) { ) : null} + {canRescan ? ( + rescanPlugin({ pluginId: plugin.id })} + > + + Re-run scan + + ) : null} +