diff --git a/SPEC.md b/SPEC.md index 48caa203..3b3c8b20 100644 --- a/SPEC.md +++ b/SPEC.md @@ -154,7 +154,7 @@ PP does **not** react to its own comments (check that the commenter's username ! "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", "ec2:DescribeSecurityGroups", - "ec2:ImportKeyPair", + "ec2:CreateKeyPair", "ec2:DeleteKeyPair", "ec2:CreateTags", "sts:GetCallerIdentity" diff --git a/backend/src/routes/api/admin.ts b/backend/src/routes/api/admin.ts index 61b148b7..549f2ae3 100644 --- a/backend/src/routes/api/admin.ts +++ b/backend/src/routes/api/admin.ts @@ -1,4 +1,5 @@ import bcrypt from "bcryptjs"; +import { randomBytes } from "crypto"; import { prisma } from "../../lib/db"; import { makeResponse } from "../../lib/response"; import { ERROR_MESSAGES } from "../../lib/errors"; @@ -43,6 +44,10 @@ export async function createUser(ctr: any) { select: { id: true, username: true, isAdmin: true, isFounder: true }, }); + // Auto-create webhook token for new users + const webhookSecret = randomBytes(32).toString("hex"); + await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } }); + return makeResponse({ ctr, content: { code: 201, data: user } }); } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 0b4f5249..7ad9a7c3 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -42,7 +42,7 @@ export async function loginHandler(ctr: any) { httpOnly: true, expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), path: "/", - sameSite: "Lax", + sameSite: "lax", }), ); @@ -116,6 +116,10 @@ export async function firstUserHandler(ctr: any) { data: { username, passwordHash: hash, isAdmin: true, isFounder: true }, }); + // Auto-create a webhook token so it's immediately available in the setup wizard + const webhookSecret = randomBytes(32).toString("hex"); + await prisma.webhookToken.create({ data: { userId: user.id, token: webhookSecret } }); + const sessionHash = randomBytes(32).toString("hex"); await prisma.session.create({ data: { hash: sessionHash, userId: user.id } }); @@ -125,7 +129,7 @@ export async function firstUserHandler(ctr: any) { httpOnly: true, expires: new Date(Date.now() + COOKIE_MAX_AGE_MS), path: "/", - sameSite: "Lax", + sameSite: "lax", }), ); diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 453e0542..373c4a62 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -86,15 +86,18 @@ async function handlePullRequestEvent(user: any, payload: any) { }); if (!repoConfig) { - const existing = await prisma.noConfigComment.findUnique({ - where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } }, - }); - if (!existing) { - const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`; - try { - await postComment(user, owner, repoName, prNumber, body); - await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } }); - } catch {} + // Only post "no config" comment on new PR opens (not on sync/close events) + if (action === "opened" || action === "reopened") { + const existing = await prisma.noConfigComment.findUnique({ + where: { userId_repoOwner_repoName_prNumber: { userId: user.id, repoOwner: owner, repoName, prNumber } }, + }); + if (!existing) { + const body = `No previews configured for \`${owner}/${repoName}\`. Configure this repo in [PR Previews](${env.PP_BASE_URL}).`; + try { + await postComment(user, owner, repoName, prNumber, body); + await prisma.noConfigComment.create({ data: { userId: user.id, repoOwner: owner, repoName, prNumber } }); + } catch {} + } } return; } diff --git a/backend/src/services/deploy.ts b/backend/src/services/deploy.ts index 94e04099..7bc77d5c 100644 --- a/backend/src/services/deploy.ts +++ b/backend/src/services/deploy.ts @@ -77,6 +77,10 @@ export async function runDeploy(jobId: number) { return; } + // Clear any stale abort signal left over from aborting the previous job for this preview. + // The new job should not be cancelled by a signal meant for the old one. + if (job.preview.id) abortSignals.delete(job.preview.id); + await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } }); const preview = job.preview as any; diff --git a/backend/src/types/node-cron.d.ts b/backend/src/types/node-cron.d.ts new file mode 100644 index 00000000..93ebf884 --- /dev/null +++ b/backend/src/types/node-cron.d.ts @@ -0,0 +1,9 @@ +declare module 'node-cron' { + interface ScheduledTask { + start(): void; + stop(): void; + destroy(): void; + } + function schedule(cronExpression: string, func: () => void, options?: { scheduled?: boolean; timezone?: string }): ScheduledTask; + export { schedule, ScheduledTask }; +} diff --git a/example.env b/example.env index 69a50fd1..ff052dbb 100644 --- a/example.env +++ b/example.env @@ -1,17 +1,20 @@ -# PP (PR Previews) - Environment Variables -# Copy to .env and fill in values +# PP (PR Previews) — Environment Variables +# Copy to .env (for local dev) or set in docker-compose.yml environment # PostgreSQL connection string DATABASE_URL=postgresql://pp:pp_password@localhost:5432/pp # Session signing secret (at least 32 random chars) -SESSION_SECRET=change_me_to_a_random_32_character_string +# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars -# Public URL of this PP instance (no trailing slash) +# Public URL of this PP instance (no trailing slash). Used in webhook URLs and PR comment links. PP_BASE_URL=https://pp.example.com -# AES-256 encryption key — 64 hex chars (32 bytes) -# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# AES-256 encryption key — exactly 64 hex chars (32 bytes). +# Used to encrypt Gitea PAT, AWS credentials, and SSH private keys at rest. +# Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# WARNING: Changing this key will invalidate all encrypted data in the database. ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 # Backend port (default: 5000) @@ -19,3 +22,6 @@ PORT=5000 # Log level: trace | debug | info | warn | error LOG_LEVEL=info + +# Node environment +NODE_ENV=production diff --git a/frontend/src/pages/SetupWizard.tsx b/frontend/src/pages/SetupWizard.tsx index 215640f5..6cbae0b0 100644 --- a/frontend/src/pages/SetupWizard.tsx +++ b/frontend/src/pages/SetupWizard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { api } from "../services/api"; import { useAuth } from "../hooks/useAuth"; @@ -28,6 +28,18 @@ export function SetupWizard() { const [webhookUrl, setWebhookUrl] = useState(""); const [webhookSecret, setWebhookSecret] = useState(""); + // Auto-load webhook info whenever we reach step 3 (index 3 = "Your Webhook") + useEffect(() => { + if (step !== 3) return; + if (webhookSecret) return; + api.user.getWebhookSecret().then(res => { + if (res.ok && res.data) { + setWebhookUrl(`${window.location.origin}/webhook/${user?.id}`); + setWebhookSecret(res.data.token); + } + }).catch(() => {}); + }, [step, user?.id]); + const next = () => setStep(s => Math.min(s + 1, STEPS.length - 1)); const skip = () => navigate("/");