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
73 changes: 52 additions & 21 deletions app/api/deploy/vercel/route.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,75 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { VERCEL_API } from "@/lib/constants/config";
import { rateLimit } from "@/lib/api-utils";

export async function POST(req: Request) {
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Mirror the same rate limit as the Netlify deploy route (5 deploys / minute)
const { allowed, remaining } = await rateLimit(
`deploy-vercel:${session.user.id}`,
5,
60_000
);

if (!allowed) {
return NextResponse.json(
{ error: "Rate limit exceeded. Please wait before deploying again." },
{
status: 429,
headers: {
"Retry-After": "60",
"X-RateLimit-Limit": "5",
"X-RateLimit-Remaining": String(remaining),
},
}
);
}

const { files, name, userApiKey } = await req.json();

if (!files || !Array.isArray(files)) {
return NextResponse.json({ error: "No files provided" }, { status: 400 });
}

// Try user key first, fallback to Editron Master Key
const token = userApiKey || process.env.VERCEL_MASTER_TOKEN;
// Require the caller to supply their own token.
// VERCEL_MASTER_TOKEN is intentionally NOT used as a fallback here —
// doing so would let any authenticated user deploy on the server's
// Vercel account without explicit per-deployment consent (issue #449).
if (typeof userApiKey !== "string") {
return NextResponse.json(
{
error:
"A Vercel API key is required. Please provide your own token in the deploy dialog.",
},
{ status: 400 }
);
}

const token = userApiKey.trim();
if (!token) {
return NextResponse.json(
{ error: "No Vercel API token provided and no master token available." },
{
error:
"A Vercel API key is required. Please provide your own token in the deploy dialog.",
},
{ status: 400 }
);
}

// Vercel API requires an array of standard file objects:
// [{ file: "index.html", data: "..." }]

// Convert our internal `TemplateData` format to flat Vercel format
const flatFiles = files.map(f => ({
const flatFiles = files.map((f: { path: string; content: string }) => ({
file: f.path,
data: f.content
data: f.content,
}));

const projectName = name ? name.toLowerCase().replace(/[^a-z0-9-]/g, '-') : "editron-deploy";
const projectName = name
? (name as string).toLowerCase().replace(/[^a-z0-9-]/g, "-")
: "editron-deploy";

const response = await fetch(VERCEL_API.DEPLOYMENTS, {
method: "POST",
Expand All @@ -45,7 +80,7 @@ export async function POST(req: Request) {
body: JSON.stringify({
name: projectName,
files: flatFiles,
target: "production"
target: "production",
}),
});

Expand All @@ -61,14 +96,10 @@ export async function POST(req: Request) {
return NextResponse.json({
url: data.url,
deploymentId: data.id,
readyState: data.readyState
readyState: data.readyState,
});

} catch (error) {
console.error("Vercel deployment error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
}
147 changes: 81 additions & 66 deletions modules/playground/components/deploy-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ interface DeployDialogProps {

export function DeployDialog({ open, onOpenChange, templateData, projectName }: DeployDialogProps) {
const [provider, setProvider] = useState<"vercel" | "netlify" | "cloudflare">("vercel");
const [useMasterKey, setUseMasterKey] = useState(true);
const [userKey, setUserKey] = useState("");
const [isDeploying, setIsDeploying] = useState(false);
const [deployedUrl, setDeployedUrl] = useState("");

const providerLabel = provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare";

const flattenFileTree = (data: TemplateFolder, parentPath = ""): { path: string; content: string }[] => {
let files: { path: string; content: string }[] = [];
if (!data) return files;
Expand All @@ -42,16 +43,16 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
const extension = item.fileExtension ? `.${item.fileExtension}` : "";
files.push({
path: `${parentPath}${item.filename}${extension}`,
content: item.content
content: item.content,
});
}
}
return files;
};

const handleDeploy = async () => {
if (!useMasterKey && !userKey) {
toast.error(`Please enter your ${provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare"} API key`);
if (!userKey.trim()) {
toast.error(`Please enter your ${providerLabel} API key.`);
return;
}

Expand All @@ -65,18 +66,18 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:

try {
const flatFiles = flattenFileTree(templateData);

// We don't deploy node_modules or big cache folders
const filteredFiles = flatFiles.filter(f => !f.path.startsWith("node_modules/") && !f.path.startsWith(".next/"));
const filteredFiles = flatFiles.filter(
(f) => !f.path.startsWith("node_modules/") && !f.path.startsWith(".next/")
);

const res = await fetch(`/api/deploy/${provider}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
files: filteredFiles,
name: projectName,
userApiKey: useMasterKey ? undefined : userKey
})
userApiKey: userKey.trim(),
}),
});

const data = await res.json();
Expand All @@ -85,12 +86,19 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
throw new Error(data.error || "Deployment failed");
}

if (typeof data.url !== "string" || !data.url.trim()) {
throw new Error("Deployment URL missing from response");
}

setDeployedUrl(data.url.startsWith("http") ? data.url : `https://${data.url}`);
toast.success("Successfully deployed!");

} catch (error: unknown) {
console.error(error);
toast.error(error instanceof Error ? error.message : "Failed to deploy. Check your API key or try again.");
toast.error(
error instanceof Error
? error.message
: "Failed to deploy. Check your API key or try again."
);
} finally {
setIsDeploying(false);
}
Expand All @@ -115,81 +123,88 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
<CheckCircle2 className="h-6 w-6 text-green-500" />
</div>
<p className="font-medium text-center">Deployment Successful!</p>
<a href={deployedUrl} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-500 hover:underline break-all text-center px-4">
<a
href={deployedUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-500 hover:underline break-all text-center px-4"
>
{deployedUrl}
</a>
<Button className="w-full mt-4" variant="outline" onClick={() => window.open(deployedUrl, '_blank')}>
<Button
className="w-full mt-4"
variant="outline"
onClick={() => window.open(deployedUrl, "_blank")}
>
Visit Site <ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<Tabs value={provider} onValueChange={(v) => { if (v === "vercel" || v === "netlify" || v === "cloudflare") { setProvider(v); } }}>
<Tabs
value={provider}
onValueChange={(v) => {
if (v === "vercel" || v === "netlify" || v === "cloudflare") {
setProvider(v);
setUserKey("");
}
}}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="vercel">Vercel</TabsTrigger>
<TabsTrigger value="netlify">Netlify</TabsTrigger>
<TabsTrigger value="cloudflare">Cloudflare</TabsTrigger>
</TabsList>
</Tabs>

<div className="rounded-md border p-4 bg-muted/30">
<div className="flex items-center justify-between mb-4">
<Label className="text-sm font-medium">Authentication Policy</Label>
</div>
<div className="space-y-4">
<div
className={`flex items-start space-x-3 rounded-lg border p-3 cursor-pointer transition-colors ${useMasterKey ? 'bg-background border-primary' : 'hover:bg-muted/50'}`}
onClick={() => setUseMasterKey(true)}
>
<div className={`mt-0.5 h-4 w-4 rounded-full border flex items-center justify-center ${useMasterKey ? 'border-primary' : 'border-muted-foreground'}`}>
{useMasterKey && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<div>
<p className="text-sm font-medium">Use Editron Deployment</p>
<p className="text-xs text-muted-foreground">Deploy immediately using our master key. Ideal for quick demos.</p>
</div>
</div>

<div
className={`flex flex-col space-y-2 rounded-lg border p-3 cursor-pointer transition-colors ${!useMasterKey ? 'bg-background border-primary' : 'hover:bg-muted/50'}`}
onClick={() => setUseMasterKey(false)}
>
<div className="flex items-start space-x-3 md:items-center">
<div className={`mt-0.5 md:mt-0 h-4 w-4 rounded-full border flex items-center justify-center shrink-0 ${!useMasterKey ? 'border-primary' : 'border-muted-foreground'}`}>
{!useMasterKey && <div className="h-2 w-2 rounded-full bg-primary" />}
</div>
<div>
<p className="text-sm font-medium">Bring Your Own Key</p>
<p className="text-xs text-muted-foreground">Deploy to your personal {provider === 'vercel' ? 'Vercel' : provider === 'netlify' ? 'Netlify' : 'Cloudflare'} account.</p>
</div>
</div>

{!useMasterKey && (
<div className="pl-7 pt-2">
<Input
placeholder={`${provider.charAt(0).toUpperCase() + provider.slice(1)} API Token`}
value={userKey}
onChange={(e) => setUserKey(e.target.value)}
type="password"
autoFocus
/>
<p className="text-[10px] text-muted-foreground mt-1.5 ml-1">
Tokens are only used for this deployment and never saved.
</p>
</div>
)}
</div>
</div>
<div className="rounded-md border p-4 bg-muted/30 space-y-3">
<Label htmlFor="api-key" className="text-sm font-medium">
{providerLabel} API Token
</Label>
<Input
id="api-key"
placeholder={`Paste your ${providerLabel} API token`}
value={userKey}
onChange={(e) => setUserKey(e.target.value)}
type="password"
/>
<p className="text-[11px] text-muted-foreground">
Tokens are used only for this deployment and are never stored.{" "}
{provider === "vercel" && (
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Get a Vercel token
</a>
)}
{provider === "netlify" && (
<a
href="https://app.netlify.com/user/applications/personal"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Get a Netlify token
</a>
)}
</p>
</div>

<Button onClick={handleDeploy} disabled={isDeploying} className="w-full">
<Button
onClick={handleDeploy}
disabled={isDeploying || !userKey.trim()}
className="w-full"
>
{isDeploying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deploying to {provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare"}...
Deploying to {providerLabel}...
</>
) : (
`Deploy to ${provider === "vercel" ? "Vercel" : provider === "netlify" ? "Netlify" : "Cloudflare"}`
`Deploy to ${providerLabel}`
)}
</Button>

Expand All @@ -203,4 +218,4 @@ export function DeployDialog({ open, onOpenChange, templateData, projectName }:
</DialogContent>
</Dialog>
);
}
}