Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/cursor/src/actions/rescan-own-plugin.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
34 changes: 33 additions & 1 deletion apps/cursor/src/components/plugins/plugin-owner-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Loader2,
MoreHorizontal,
Pencil,
RefreshCw,
Trash2,
} from "lucide-react";
import Link from "next/link";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
{
Expand All @@ -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 (
<>
Expand Down Expand Up @@ -192,6 +213,17 @@ export function PluginOwnerMenu({ plugin }: Props) {
</DropdownMenuItem>
) : null}

{canRescan ? (
<DropdownMenuItem
className={menuItemClass}
disabled={busy}
onClick={() => rescanPlugin({ pluginId: plugin.id })}
>
<RefreshCw className="size-4 shrink-0" />
Re-run scan
</DropdownMenuItem>
) : null}

<DropdownMenuSeparator />

<DropdownMenuItem
Expand Down