feat: initial scaffold - backend, frontend, Prisma schema, Docker

- Prisma schema: User, Session, RepoConfig, Preview, Job, WebhookToken, NoConfigComment, AdminSettings
- Backend: auth (login/logout/me/first-user setup), webhook handler with HMAC verification, EC2 service, SSH service, deploy pipeline, job queue worker, cron workers
- Frontend: Login with first-user detection, Dashboard, PreviewDetail with live log streaming, Settings, Repos config, Admin panel, SetupWizard, Privacy page
- Docker Compose and Dockerfile for self-hosted deployment
- Uses bcryptjs for Node 24 compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:18:08 +02:00
parent ca2efdadea
commit 40d484bede
108 changed files with 13393 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { env } from "./lib/env";
import { Server, Cookie } from "rjweb-server";
import { Runtime } from "@rjweb/runtime-node";
import { existsSync } from "node:fs";
import { join } from "path";
import { prisma } from "./lib/db";
import { logger } from "./lib/logger";
import { corsMiddleware } from "./lib/middlewares/cors";
import { mainMiddleware } from "./lib/middlewares/main";
import { authResolutionMiddleware, authEnforcementMiddleware } from "./lib/middlewares/auth";
import { makeResponse } from "./lib/response";
import { ERROR_MESSAGES } from "./lib/errors";
import { startJobWorker } from "./workers/jobWorker";
import { startCronWorkers } from "./workers/cronWorker";
import { getAdminSettings } from "./lib/adminSettings";
import { loginHandler, logoutHandler, meHandler, setupStatusHandler, firstUserHandler } from "./routes/auth";
import { webhookHandler } from "./routes/webhook";
import { getUserSettings, updateUsername, updatePassword, updateGitea, updateAws, getWebhookSecret, regenerateWebhookSecret } from "./routes/api/user";
import { listRepos, saveRepoConfig, toggleRepoEnabled, getRepoConfig } from "./routes/api/repos";
import { listPreviews, getPreview, stopPreviewRoute, previewLogsWs } from "./routes/api/previews";
import { listUsers, createUser, updateUser, deleteUser, getSettings, updateSettings, adminListPreviews, adminStopPreview } from "./routes/api/admin";
const uiBuildPath = join(__dirname, "../../frontend/dist");
const uiIndexPath = join(uiBuildPath, "index.html");
const hasUiBuild = existsSync(uiBuildPath);
export const server = new Server(
Runtime,
{
port: env.PORT,
bind: "0.0.0.0",
version: false,
performance: { lastModified: false, eTag: false },
logging: { warn: true, debug: false, error: true },
},
[
corsMiddleware.use({}),
mainMiddleware.use({}),
authResolutionMiddleware.use({}),
authEnforcementMiddleware.use({}),
],
);
// Auth
server.path("/api/auth", (path) => path
.http("POST", "/login", (http) => http.onRequest(loginHandler))
.http("POST", "/logout", (http) => http.onRequest(logoutHandler))
.http("GET", "/me", (http) => http.onRequest(meHandler))
.http("GET", "/setup-status", (http) => http.onRequest(setupStatusHandler))
.http("POST", "/first-user", (http) => http.onRequest(firstUserHandler))
);
// Webhook
server.path("/webhook", (path) => path
.http("POST", "/:userId", (http) => http.onRequest(webhookHandler))
);
// User settings
server.path("/api/user", (path) => path
.http("GET", "/settings", (http) => http.onRequest(getUserSettings))
.http("PATCH", "/username", (http) => http.onRequest(updateUsername))
.http("PATCH", "/password", (http) => http.onRequest(updatePassword))
.http("PUT", "/gitea", (http) => http.onRequest(updateGitea))
.http("PUT", "/aws", (http) => http.onRequest(updateAws))
.http("GET", "/webhook-secret", (http) => http.onRequest(getWebhookSecret))
.http("POST", "/webhook-secret/regenerate", (http) => http.onRequest(regenerateWebhookSecret))
);
// Repos
server.path("/api/repos", (path) => path
.http("GET", "/", (http) => http.onRequest(listRepos))
.http("POST", "/config", (http) => http.onRequest(saveRepoConfig))
.http("POST", "/toggle", (http) => http.onRequest(toggleRepoEnabled))
.http("GET", "/:owner/:repo/config", (http) => http.onRequest(getRepoConfig))
);
// Previews
server.path("/api/previews", (path) => path
.http("GET", "/", (http) => http.onRequest(listPreviews))
.http("GET", "/:id", (http) => http.onRequest(getPreview))
.http("POST", "/:id/stop", (http) => http.onRequest(stopPreviewRoute))
.ws("/:id/logs", (ws) => ws
.onOpen(previewLogsWs)
.onMessage(async () => {})
.onClose(async () => {})
)
);
// Admin
server.path("/api/admin", (path) => path
.http("GET", "/users", (http) => http.onRequest(listUsers))
.http("POST", "/users", (http) => http.onRequest(createUser))
.http("PATCH", "/users/:id", (http) => http.onRequest(updateUser))
.http("DELETE", "/users/:id", (http) => http.onRequest(deleteUser))
.http("GET", "/settings", (http) => http.onRequest(getSettings))
.http("PUT", "/settings", (http) => http.onRequest(updateSettings))
.http("GET", "/previews", (http) => http.onRequest(adminListPreviews))
.http("POST", "/previews/:id/stop", (http) => http.onRequest(adminStopPreview))
);
// Static UI
if (hasUiBuild) {
server.path("/", (path) => path.static(uiBuildPath));
}
server.notFound(async (ctr) => {
const STATIC_EXT = /\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|json|txt|xml|webp|avif)(\?.*)?$/i;
if (!ctr.url.path.startsWith("/api") && !STATIC_EXT.test(ctr.url.path) && existsSync(uiIndexPath)) {
return ctr.status(200).printFile(uiIndexPath, { addTypes: true });
}
return makeResponse({ ctr, content: { code: ERROR_MESSAGES.NOT_FOUND.code, message: ERROR_MESSAGES.NOT_FOUND.message } });
});
server.error("httpRequest", async (ctr, error) => {
logger.error(error, "Unhandled HTTP request error");
return makeResponse({ ctr, content: { code: ERROR_MESSAGES.INTERNAL_SERVER_ERROR.code } });
});
server
.start()
.then(async (port) => {
await prisma.$connect();
logger.info({ port }, "PP backend running");
await getAdminSettings();
startJobWorker();
startCronWorkers();
logger.info("All workers started");
})
.catch((err) => logger.error(err, "Server failed to start"));
+9
View File
@@ -0,0 +1,9 @@
import { prisma } from "./db";
export async function getAdminSettings() {
let settings = await prisma.adminSettings.findUnique({ where: { id: 1 } });
if (!settings) {
settings = await prisma.adminSettings.create({ data: { id: 1 } });
}
return settings;
}
+6
View File
@@ -0,0 +1,6 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: ["error", "warn"],
errorFormat: "pretty",
});
+23
View File
@@ -0,0 +1,23 @@
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
import { env } from "./env";
const ALGORITHM = "aes-256-gcm";
const KEY = Buffer.from(env.ENCRYPTION_KEY, "hex");
export function encrypt(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]).toString("base64");
}
export function decrypt(ciphertext: string): string {
const buf = Buffer.from(ciphertext, "base64");
const iv = buf.slice(0, 12);
const authTag = buf.slice(12, 28);
const encrypted = buf.slice(28);
const decipher = createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
+26
View File
@@ -0,0 +1,26 @@
import dotenv from "dotenv";
import { join } from "path";
import { z } from "zod";
dotenv.config({ path: join(__dirname, "../../.env") });
const schema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
DATABASE_URL: z.string().min(1),
PORT: z.coerce.number().int().positive().default(5000),
SESSION_SECRET: z.string().min(16),
PP_BASE_URL: z.string().min(1),
ENCRYPTION_KEY: z.string().length(64, "ENCRYPTION_KEY must be 64 hex chars (32 bytes AES-256)"),
LOG_LEVEL: z.string().default("info"),
});
const result = schema.safeParse(process.env);
if (!result.success) {
const formatted = result.error.issues
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
.join("\n");
throw new Error(`Invalid environment variables:\n${formatted}`);
}
export const env = result.data;
+9
View File
@@ -0,0 +1,9 @@
export const ERROR_MESSAGES = {
UNAUTHORIZED: { code: 401, message: "You are not authorized to access this resource." },
FORBIDDEN: { code: 403, message: "You do not have permission to access this resource." },
NOT_FOUND: { code: 404, message: "The requested resource was not found." },
INTERNAL_SERVER_ERROR: { code: 500, message: "An unexpected server error has occurred." },
BAD_REQUEST: { code: 400, message: "The request was invalid or malformed." },
CONFLICT: { code: 409, message: "The request conflicts with the current state of the resource." },
TOO_MANY_REQUESTS: { code: 429, message: "Too many requests. Please try again later." },
} as const;
+14
View File
@@ -0,0 +1,14 @@
import { env } from "./env";
import pino from "pino";
export const logger = pino({
level: env.LOG_LEVEL,
transport:
env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true } }
: undefined,
});
export function createLogger(component: string) {
return logger.child({ component });
}
+83
View File
@@ -0,0 +1,83 @@
import { Middleware } from "rjweb-server";
import { type User } from "@prisma/client";
import { prisma } from "../db";
import { createLogger } from "../logger";
import { ERROR_MESSAGES } from "../errors";
const log = createLogger("AUTH");
const COOKIE_NAME = "pp_session";
export type AuthState =
| { success: true; user: User; sessionId: number }
| { success: false; message: string; tokenProvided: boolean };
type AuthContext = {
auth?: AuthState;
};
export const authResolutionMiddleware = new Middleware<{}, AuthContext>(
"Auth Resolution Middleware",
"1.0.0",
)
.load(() => {
log.info("Auth resolution middleware loaded");
})
.httpRequest(async (_config, _server, context, ctr) => {
const cookieToken = ctr.cookies.get(COOKIE_NAME);
const tokenProvided = Boolean(cookieToken);
const data = context.data(authResolutionMiddleware);
if (!cookieToken) {
data.auth = { success: false, message: "No session", tokenProvided: false };
return;
}
const session = await prisma.session.findFirst({
where: { hash: cookieToken },
include: { user: true },
});
if (!session) {
data.auth = { success: false, message: "Invalid session", tokenProvided };
return;
}
data.auth = { success: true, user: session.user, sessionId: session.id };
})
.httpRequestContext(
(_config, Original) =>
class extends Original {
getAuth(): AuthState {
const data = this.context.data(authResolutionMiddleware);
if (!data.auth) {
return { success: false, message: "Auth not resolved", tokenProvided: false };
}
return data.auth;
}
},
)
.export();
export const authEnforcementMiddleware = new Middleware<{}, {}>(
"Auth Enforcement Middleware",
"1.0.0",
)
.httpRequest(async (_config, _server, context, ctr, end) => {
const data = context.data(authResolutionMiddleware) as AuthContext;
const auth = data.auth;
if (!auth || auth.success || !auth.tokenProvided) {
return;
}
return end(
ctr.status(ERROR_MESSAGES.UNAUTHORIZED.code).print({
status: "FAILED",
message: auth.message,
}),
);
})
.export();
export const COOKIE_NAME_EXPORT = COOKIE_NAME;
+16
View File
@@ -0,0 +1,16 @@
import { Middleware } from "rjweb-server";
import { env } from "../env";
const ALLOWED_ORIGINS = new Set([env.PP_BASE_URL]);
export const corsMiddleware = new Middleware<{}, {}>("CORS Middleware", "1.0.0")
.httpRequest(async (_config, _server, _context, ctr) => {
const origin = ctr.headers.get("origin") || "";
if (ALLOWED_ORIGINS.has(origin) || env.NODE_ENV === "development") {
ctr.headers.set("Access-Control-Allow-Origin", origin || "*");
ctr.headers.set("Access-Control-Allow-Credentials", "true");
ctr.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
ctr.headers.set("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With");
}
})
.export();
+15
View File
@@ -0,0 +1,15 @@
import { Middleware } from "rjweb-server";
import { createLogger } from "../logger";
const log = createLogger("HTTP");
export const mainMiddleware = new Middleware<{}, {}>("Main Middleware", "1.0.0")
.load(() => {
log.info("Main middleware loaded");
})
.httpRequest(async (_config, _server, _context, ctr) => {
if (ctr.url.method === "OPTIONS") {
ctr.status(204).print("");
}
})
.export();
+49
View File
@@ -0,0 +1,49 @@
import { ERROR_MESSAGES } from "./errors";
type ResponseContent =
| { code: number; message?: string; data?: unknown }
| { status: number; message?: string; data?: unknown };
function resolve(content: ResponseContent) {
const code = "code" in content ? content.code : content.status;
const message = code >= 500 ? ERROR_MESSAGES.INTERNAL_SERVER_ERROR.message : content.message;
return { code, message };
}
function buildBody(code: number, message: string | undefined, data: unknown) {
if (code >= 400) {
return { status: "FAILED", message };
}
return {
status: "OK",
...(message !== undefined ? { message } : {}),
...(data !== undefined ? { data } : {}),
};
}
export async function makeResponse({
ctr,
content,
}: {
ctr: any;
content: ResponseContent;
}) {
const { code, message } = resolve(content);
const data = "data" in content ? content.data : undefined;
return ctr.status(code).print(buildBody(code, message, data));
}
export async function endResponse({
ctr,
end,
content,
}: {
ctr: any;
end: () => void;
content: ResponseContent;
}) {
const { code, message } = resolve(content);
const data = "data" in content ? content.data : undefined;
ctr.status(code).print(buildBody(code, message, data));
end();
}
+170
View File
@@ -0,0 +1,170 @@
import bcrypt from "bcryptjs";
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { getAdminSettings } from "../../lib/adminSettings";
function requireAdmin(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) return null;
if (!auth.user.isAdmin) return null;
return auth.user;
}
export async function listUsers(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const users = await prisma.user.findMany({
select: { id: true, username: true, isAdmin: true, isFounder: true, createdAt: true, giteaInstanceUrl: true },
orderBy: { createdAt: "asc" },
});
return makeResponse({ ctr, content: { code: 200, data: users } });
}
export async function createUser(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const body = await ctr.body();
const { username, password } = body || {};
if (!username || !password) return makeResponse({ ctr, content: { code: 400, message: "Username and password required" } });
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username already taken" } });
const userCount = await prisma.user.count();
const isFirst = userCount === 0;
const hash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { username, passwordHash: hash, isAdmin: isFirst, isFounder: isFirst },
select: { id: true, username: true, isAdmin: true, isFounder: true },
});
return makeResponse({ ctr, content: { code: 201, data: user } });
}
export async function updateUser(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
const body = await ctr.body();
const { username, password, isAdmin } = body || {};
const data: any = {};
if (username) {
const existing = await prisma.user.findFirst({ where: { username, id: { not: id } } });
if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username taken" } });
data.username = username;
}
if (password) data.passwordHash = await bcrypt.hash(password, 12);
if (isAdmin !== undefined && !target.isFounder) data.isAdmin = Boolean(isAdmin);
await prisma.user.update({ where: { id }, data });
return makeResponse({ ctr, content: { code: 200, message: "User updated" } });
}
export async function deleteUser(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
if (target.isFounder) return makeResponse({ ctr, content: { code: 403, message: "Cannot delete founder" } });
if (id === admin.id) return makeResponse({ ctr, content: { code: 403, message: "Cannot delete yourself" } });
await prisma.user.delete({ where: { id } });
return makeResponse({ ctr, content: { code: 200, message: "User deleted" } });
}
export async function getSettings(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const settings = await getAdminSettings();
return makeResponse({ ctr, content: { code: 200, data: settings } });
}
export async function updateSettings(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const body = await ctr.body();
const {
defaultInstanceType,
maxConcurrentInstancesPerUser,
logSizeLimitBytes,
previewRetentionDays,
webhookRateLimitPerMinute,
contactEmail,
} = body || {};
const data: any = {};
if (defaultInstanceType) data.defaultInstanceType = defaultInstanceType;
if (maxConcurrentInstancesPerUser) data.maxConcurrentInstancesPerUser = Number(maxConcurrentInstancesPerUser);
if (logSizeLimitBytes) data.logSizeLimitBytes = Number(logSizeLimitBytes);
if (previewRetentionDays) data.previewRetentionDays = Number(previewRetentionDays);
if (webhookRateLimitPerMinute) data.webhookRateLimitPerMinute = Number(webhookRateLimitPerMinute);
if (contactEmail !== undefined) data.contactEmail = contactEmail;
await prisma.adminSettings.upsert({
where: { id: 1 },
update: data,
create: { id: 1, ...data },
});
return makeResponse({ ctr, content: { code: 200, message: "Settings updated" } });
}
export async function adminListPreviews(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const previews = await prisma.preview.findMany({
include: {
repoConfig: { include: { user: { select: { id: true, username: true } } } },
},
orderBy: { updatedAt: "desc" },
take: 200,
});
return makeResponse({
ctr, content: {
code: 200, data: previews.map(p => ({
id: p.id,
prNumber: p.prNumber,
prTitle: p.prTitle,
status: p.status,
instanceIp: p.instanceIp,
port: p.port,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName,
user: (p.repoConfig as any).user,
}))
}
});
}
export async function adminStopPreview(ctr: any) {
const admin = requireAdmin(ctr);
if (!admin) return makeResponse({ ctr, content: { code: 403, message: ERROR_MESSAGES.FORBIDDEN.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findUnique({ where: { id } });
if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
await prisma.job.create({
data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Admin stop" } },
});
return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } });
}
+133
View File
@@ -0,0 +1,133 @@
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { subscribeToLogs } from "../../services/deploy";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) return null;
return auth.user;
}
export async function listPreviews(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const previews = await prisma.preview.findMany({
where: { repoConfig: { userId: user.id } },
include: { repoConfig: { select: { repoOwner: true, repoName: true } } },
orderBy: { updatedAt: "desc" },
});
return makeResponse({
ctr, content: {
code: 200, data: previews.map(p => ({
id: p.id,
prNumber: p.prNumber,
prTitle: p.prTitle,
commitSha: p.commitSha,
status: p.status,
instanceIp: p.instanceIp,
port: p.port,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
lastActivityAt: p.lastActivityAt,
repoOwner: p.repoConfig.repoOwner,
repoName: p.repoConfig.repoName,
}))
}
});
}
export async function getPreview(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findFirst({
where: { id, repoConfig: { userId: user.id } },
include: {
repoConfig: { select: { repoOwner: true, repoName: true } },
jobs: { orderBy: { createdAt: "desc" }, take: 10 },
},
});
if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
return makeResponse({
ctr, content: {
code: 200, data: {
id: preview.id,
prNumber: preview.prNumber,
prTitle: preview.prTitle,
commitSha: preview.commitSha,
status: preview.status,
instanceIp: preview.instanceIp,
port: preview.port,
logs: preview.logs,
createdAt: preview.createdAt,
updatedAt: preview.updatedAt,
stoppedAt: preview.stoppedAt,
lastActivityAt: preview.lastActivityAt,
repoOwner: preview.repoConfig.repoOwner,
repoName: preview.repoConfig.repoName,
jobs: preview.jobs.map(j => ({
id: j.id,
type: j.type,
status: j.status,
createdAt: j.createdAt,
startedAt: j.startedAt,
finishedAt: j.finishedAt,
error: j.error,
})),
}
}
});
}
export async function stopPreviewRoute(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findFirst({
where: { id, repoConfig: { userId: user.id } },
});
if (!preview) return makeResponse({ ctr, content: { code: 404, message: ERROR_MESSAGES.NOT_FOUND.message } });
if (preview.status === "STOPPED") return makeResponse({ ctr, content: { code: 400, message: "Already stopped" } });
await prisma.job.create({
data: { previewId: id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop via UI" } },
});
return makeResponse({ ctr, content: { code: 200, message: "Stop job enqueued" } });
}
export async function previewLogsWs(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) {
ctr.close(1008, "Unauthorized");
return;
}
const id = parseInt(ctr.params.get("id") || "0", 10);
const preview = await prisma.preview.findFirst({
where: { id, repoConfig: { userId: auth.user.id } },
});
if (!preview) {
ctr.close(1008, "Not found");
return;
}
await ctr.print(JSON.stringify({ type: "init", logs: preview.logs }));
const unsub = subscribeToLogs(id, (text) => {
try {
ctr.print(JSON.stringify({ type: "append", text }));
} catch {}
});
ctr.$abort(unsub);
}
+172
View File
@@ -0,0 +1,172 @@
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { fetchUserRepos, registerWebhook, deleteWebhook } from "../../services/gitea";
import { getAdminSettings } from "../../lib/adminSettings";
import { env } from "../../lib/env";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) return null;
return auth.user;
}
export async function listRepos(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const fullUser = await prisma.user.findUnique({ where: { id: user.id } });
if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) {
return makeResponse({ ctr, content: { code: 400, message: "Gitea credentials not configured" } });
}
const giteaRepos = await fetchUserRepos(fullUser as any);
const configs = await prisma.repoConfig.findMany({ where: { userId: user.id } });
const configMap = new Map(configs.map(c => [`${c.repoOwner}/${c.repoName}`, c]));
const allOwners = [...new Set(giteaRepos.map((r: any) => r.full_name?.split("/")[0]).filter(Boolean))];
const allConfigs = await prisma.repoConfig.findMany({
where: { repoOwner: { in: allOwners } },
select: { repoOwner: true, repoName: true, userId: true },
});
const claimedByOthers = new Set(
allConfigs.filter(c => c.userId !== user.id).map(c => `${c.repoOwner}/${c.repoName}`)
);
const result = giteaRepos.map((r: any) => {
const [owner, name] = (r.full_name || "").split("/");
const key = `${owner}/${name}`;
const config = configMap.get(key);
const { giteaWebhookId, ...safeConfig } = config || {} as any;
return {
owner,
name,
fullName: r.full_name,
htmlUrl: r.html_url,
isEnabled: config?.isEnabled ?? false,
claimedByOther: claimedByOthers.has(key),
config: config ? safeConfig : null,
};
});
return makeResponse({ ctr, content: { code: 200, data: result } });
}
export async function getRepoConfig(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const owner = ctr.params.get("owner");
const repo = ctr.params.get("repo");
const config = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName: repo, userId: user.id },
});
if (!config) return makeResponse({ ctr, content: { code: 404, message: "Repo config not found" } });
const { giteaWebhookId, ...safeConfig } = config;
return makeResponse({ ctr, content: { code: 200, data: safeConfig } });
}
export async function saveRepoConfig(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { owner, repo, ...configData } = body || {};
if (!owner || !repo) return makeResponse({ ctr, content: { code: 400, message: "owner and repo required" } });
const existing = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName: repo },
});
if (existing && existing.userId !== user.id) {
return makeResponse({ ctr, content: { code: 409, message: "This repo is already configured by another user." } });
}
const settings = await getAdminSettings();
const sanitized = {
repoOwner: owner,
repoName: repo,
userId: user.id,
instanceType: configData.instanceType ?? settings.defaultInstanceType,
inactivityHours: Math.min(72, Math.max(0.5, Number(configData.inactivityHours ?? 12))),
port: Number(configData.port ?? 3000),
envVars: configData.envVars ?? {},
useDockerCompose: Boolean(configData.useDockerCompose ?? false),
composeFilePath: configData.composeFilePath ?? null,
aptPackages: Array.isArray(configData.aptPackages) ? configData.aptPackages : [],
setupCommands: Array.isArray(configData.setupCommands) ? configData.setupCommands : [],
buildCommands: Array.isArray(configData.buildCommands) ? configData.buildCommands : [],
postBuildCommands: Array.isArray(configData.postBuildCommands) ? configData.postBuildCommands : [],
runCommand: configData.runCommand ?? null,
denyList: Array.isArray(configData.denyList) ? configData.denyList : [],
};
let config;
if (existing) {
config = await prisma.repoConfig.update({ where: { id: existing.id }, data: sanitized });
} else {
config = await prisma.repoConfig.create({ data: sanitized });
}
const { giteaWebhookId, ...safeConfig } = config;
return makeResponse({ ctr, content: { code: 200, data: safeConfig } });
}
export async function toggleRepoEnabled(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { owner, repo, enabled } = body || {};
if (!owner || !repo || enabled === undefined) {
return makeResponse({ ctr, content: { code: 400, message: "owner, repo, enabled required" } });
}
const fullUser = await prisma.user.findUnique({ where: { id: user.id } });
if (!fullUser?.giteaInstanceUrl || !fullUser?.giteaPAT) {
return makeResponse({ ctr, content: { code: 400, message: "Gitea credentials not configured" } });
}
let config = await prisma.repoConfig.findFirst({ where: { repoOwner: owner, repoName: repo } });
if (config && config.userId !== user.id) {
return makeResponse({ ctr, content: { code: 409, message: "This repo is already configured by another user." } });
}
const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } });
if (!webhookToken) {
return makeResponse({ ctr, content: { code: 400, message: "No webhook token configured. Go to Settings to generate one." } });
}
if (enabled) {
if (!config) {
config = await prisma.repoConfig.create({
data: { repoOwner: owner, repoName: repo, userId: user.id, isEnabled: false },
});
}
const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`;
const hookId = await registerWebhook(fullUser as any, owner, repo, webhookUrl, webhookToken.token);
await prisma.repoConfig.update({
where: { id: config.id },
data: { isEnabled: true, giteaWebhookId: String(hookId) },
});
return makeResponse({ ctr, content: { code: 200, message: "Repo enabled and webhook registered" } });
} else {
if (!config) return makeResponse({ ctr, content: { code: 404, message: "Repo config not found" } });
if (config.giteaWebhookId) {
try {
await deleteWebhook(fullUser as any, owner, repo, config.giteaWebhookId);
} catch (e: any) {
return makeResponse({ ctr, content: { code: 400, message: `Failed to delete Gitea webhook: ${e.message}` } });
}
}
await prisma.repoConfig.update({
where: { id: config.id },
data: { isEnabled: false, giteaWebhookId: null },
});
return makeResponse({ ctr, content: { code: 200, message: "Repo disabled and webhook removed" } });
}
}
+182
View File
@@ -0,0 +1,182 @@
import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
import { prisma } from "../../lib/db";
import { makeResponse } from "../../lib/response";
import { ERROR_MESSAGES } from "../../lib/errors";
import { encrypt, decrypt } from "../../lib/encryption";
import { validateGiteaUrl } from "../../services/gitea";
import { validateAwsCredentials } from "../../services/ec2";
import { env } from "../../lib/env";
function requireAuth(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) return null;
return auth.user;
}
export async function getUserSettings(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const webhookToken = await prisma.webhookToken.findUnique({ where: { userId: user.id } });
const fullUser = await prisma.user.findUnique({ where: { id: user.id } });
return makeResponse({
ctr, content: {
code: 200, data: {
id: user.id,
username: user.username,
giteaUsername: fullUser?.giteaUsername,
giteaInstanceUrl: fullUser?.giteaInstanceUrl,
giteaPatSet: !!fullUser?.giteaPAT,
awsAccessKeyId: fullUser?.awsAccessKeyId ? "****" : null,
awsRegion: fullUser?.awsRegion,
webhookUrl: `${env.PP_BASE_URL}/webhook/${user.id}`,
webhookSecret: webhookToken?.token ? "••••••••" : null,
webhookTokenExists: !!webhookToken,
}
}
});
}
export async function updateUsername(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { username } = body || {};
if (!username || typeof username !== "string") return makeResponse({ ctr, content: { code: 400, message: "Username required" } });
const existing = await prisma.user.findFirst({ where: { username, id: { not: user.id } } });
if (existing) return makeResponse({ ctr, content: { code: 409, message: "Username already taken" } });
await prisma.user.update({ where: { id: user.id }, data: { username } });
return makeResponse({ ctr, content: { code: 200, message: "Username updated" } });
}
export async function updatePassword(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { currentPassword, newPassword } = body || {};
if (!currentPassword || !newPassword) return makeResponse({ ctr, content: { code: 400, message: "Current and new passwords required" } });
const fullUser = await prisma.user.findUnique({ where: { id: user.id } });
if (!fullUser) return makeResponse({ ctr, content: { code: 404, message: "User not found" } });
const valid = await bcrypt.compare(currentPassword, fullUser.passwordHash);
if (!valid) return makeResponse({ ctr, content: { code: 401, message: "Current password incorrect" } });
const hash = await bcrypt.hash(newPassword, 12);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: hash } });
return makeResponse({ ctr, content: { code: 200, message: "Password updated" } });
}
export async function updateGitea(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { giteaInstanceUrl, giteaUsername, giteaPAT } = body || {};
if (!giteaInstanceUrl || !giteaUsername) {
return makeResponse({ ctr, content: { code: 400, message: "Gitea URL and username required" } });
}
const cleanUrl = giteaInstanceUrl.replace(/\/+$/, "");
const validation = await validateGiteaUrl(cleanUrl);
if (!validation.success) {
return makeResponse({ ctr, content: { code: 400, message: `Gitea validation failed: ${validation.error}` } });
}
const data: any = { giteaInstanceUrl: cleanUrl, giteaUsername };
if (giteaPAT) data.giteaPAT = encrypt(giteaPAT);
await prisma.user.update({ where: { id: user.id }, data });
return makeResponse({ ctr, content: { code: 200, message: `Connected to Gitea ${validation.version}`, data: { version: validation.version } } });
}
export async function updateAws(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const body = await ctr.body();
const { awsAccessKeyId, awsSecretAccessKey, awsRegion } = body || {};
if (!awsAccessKeyId || !awsSecretAccessKey || !awsRegion) {
return makeResponse({ ctr, content: { code: 400, message: "AWS credentials and region required" } });
}
const tempUser = {
...user,
awsAccessKeyId: encrypt(awsAccessKeyId),
awsSecretAccessKey: encrypt(awsSecretAccessKey),
awsRegion,
};
const validation = await validateAwsCredentials(tempUser as any);
if (!validation.success) {
return makeResponse({ ctr, content: { code: 400, message: `AWS validation failed: ${validation.error}` } });
}
await prisma.user.update({
where: { id: user.id },
data: {
awsAccessKeyId: encrypt(awsAccessKeyId),
awsSecretAccessKey: encrypt(awsSecretAccessKey),
awsRegion,
},
});
return makeResponse({ ctr, content: { code: 200, message: `Connected as ${validation.arn}`, data: { arn: validation.arn } } });
}
export async function getWebhookSecret(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
let token = await prisma.webhookToken.findUnique({ where: { userId: user.id } });
if (!token) {
const secret = randomBytes(32).toString("hex");
token = await prisma.webhookToken.create({ data: { userId: user.id, token: secret } });
}
return makeResponse({ ctr, content: { code: 200, data: { token: token.token } } });
}
export async function regenerateWebhookSecret(ctr: any) {
const user = requireAuth(ctr);
if (!user) return makeResponse({ ctr, content: { code: 401, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
const newSecret = randomBytes(32).toString("hex");
await prisma.webhookToken.upsert({
where: { userId: user.id },
update: { token: newSecret },
create: { userId: user.id, token: newSecret },
});
const fullUser = await prisma.user.findUnique({ where: { id: user.id } });
if (!fullUser?.giteaInstanceUrl) {
return makeResponse({ ctr, content: { code: 200, message: "Secret regenerated (no Gitea hooks to update)", data: { token: newSecret } } });
}
const configs = await prisma.repoConfig.findMany({
where: { userId: user.id, giteaWebhookId: { not: null } },
});
const { updateWebhookSecret } = await import("../../services/gitea");
const webhookUrl = `${env.PP_BASE_URL}/webhook/${user.id}`;
const errors: string[] = [];
for (const config of configs) {
try {
await updateWebhookSecret(fullUser as any, config.repoOwner, config.repoName, config.giteaWebhookId!, webhookUrl, newSecret);
} catch (e: any) {
errors.push(`${config.repoOwner}/${config.repoName}: ${e.message}`);
}
}
return makeResponse({
ctr, content: {
code: 200,
message: errors.length ? `Secret regenerated with ${errors.length} hook update errors` : "Secret regenerated and all hooks updated",
data: { token: newSecret, errors },
}
});
}
+134
View File
@@ -0,0 +1,134 @@
import { randomBytes } from "crypto";
import bcrypt from "bcryptjs";
import { Cookie } from "rjweb-server";
import { prisma } from "../lib/db";
import { makeResponse } from "../lib/response";
import { ERROR_MESSAGES } from "../lib/errors";
import { createLogger } from "../lib/logger";
const log = createLogger("AUTH_ROUTE");
const COOKIE_NAME = "pp_session";
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export async function loginHandler(ctr: any) {
let body: any;
try {
body = await ctr.body();
} catch {
return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } });
}
const { username, password } = body || {};
if (!username || !password) {
return makeResponse({ ctr, content: { code: 400, message: "Username and password required" } });
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return makeResponse({ ctr, content: { code: 401, message: "Invalid credentials" } });
}
const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
ctr.cookies.set(
COOKIE_NAME,
new Cookie(sessionHash, {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "Lax",
}),
);
return makeResponse({ ctr, content: { code: 200, data: { id: user.id, username: user.username, isAdmin: user.isAdmin } } });
}
export async function logoutHandler(ctr: any) {
const auth = ctr.getAuth?.();
if (auth?.success) {
await prisma.session.delete({ where: { id: auth.sessionId } }).catch(() => {});
}
ctr.cookies.set(COOKIE_NAME, new Cookie("", { expires: new Date(0), path: "/" }));
return makeResponse({ ctr, content: { code: 200, message: "Logged out" } });
}
export async function meHandler(ctr: any) {
const auth = ctr.getAuth?.();
if (!auth?.success) {
return makeResponse({ ctr, content: { code: ERROR_MESSAGES.UNAUTHORIZED.code, message: ERROR_MESSAGES.UNAUTHORIZED.message } });
}
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
if (!user) return makeResponse({ ctr, content: { code: 404, message: "User not found" } });
return makeResponse({
ctr,
content: {
code: 200,
data: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin,
isFounder: user.isFounder,
giteaUsername: user.giteaUsername,
giteaInstanceUrl: user.giteaInstanceUrl,
giteaPatSet: !!user.giteaPAT,
awsAccessKeyId: user.awsAccessKeyId ? "****" : null,
awsRegion: user.awsRegion,
awsConfigured: !!(user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),
setupComplete: !!(user.giteaInstanceUrl && user.giteaPAT && user.awsAccessKeyId && user.awsSecretAccessKey && user.awsRegion),
},
},
});
}
export async function setupStatusHandler(ctr: any) {
const count = await prisma.user.count();
return makeResponse({ ctr, content: { code: 200, data: { needsSetup: count === 0 } } });
}
export async function firstUserHandler(ctr: any) {
const count = await prisma.user.count();
if (count > 0) {
return makeResponse({ ctr, content: { code: 403, message: "Setup already completed. Contact an administrator to create your account." } });
}
let body: any;
try {
body = await ctr.body();
} catch {
return makeResponse({ ctr, content: { code: 400, message: "Invalid body" } });
}
const { username, password } = body || {};
if (!username || !password || password.length < 8) {
return makeResponse({ ctr, content: { code: 400, message: "Username and password (min 8 chars) required" } });
}
const hash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { username, passwordHash: hash, isAdmin: true, isFounder: true },
});
const sessionHash = randomBytes(32).toString("hex");
await prisma.session.create({ data: { hash: sessionHash, userId: user.id } });
ctr.cookies.set(
COOKIE_NAME,
new Cookie(sessionHash, {
httpOnly: true,
expires: new Date(Date.now() + COOKIE_MAX_AGE_MS),
path: "/",
sameSite: "Lax",
}),
);
log.info({ username }, "First user (founder) created");
return makeResponse({ ctr, content: { code: 201, data: { id: user.id, username: user.username, isAdmin: true, isFounder: true } } });
}
+279
View File
@@ -0,0 +1,279 @@
import { createHmac } from "crypto";
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { getAdminSettings } from "../lib/adminSettings";
import { buildPrCommentBody, postComment } from "../services/gitea";
import { env } from "../lib/env";
const log = createLogger("WEBHOOK");
const rateLimitMap = new Map<number, { count: number; resetAt: number }>();
function checkRateLimit(userId: number, limitPerMin: number): boolean {
const now = Date.now();
const entry = rateLimitMap.get(userId);
if (!entry || now > entry.resetAt) {
rateLimitMap.set(userId, { count: 1, resetAt: now + 60_000 });
return true;
}
if (entry.count >= limitPerMin) return false;
entry.count++;
return true;
}
export async function webhookHandler(ctr: any) {
const userId = parseInt(ctr.params.get("userId") || "0", 10);
if (!userId) return ctr.status(400).print({ status: "FAILED", message: "Invalid user ID" });
const settings = await getAdminSettings();
if (!checkRateLimit(userId, settings.webhookRateLimitPerMinute)) {
return ctr.status(429).print({ status: "FAILED", message: "Rate limit exceeded" });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return ctr.status(404).print({ status: "FAILED", message: "User not found" });
const webhookToken = await prisma.webhookToken.findUnique({ where: { userId } });
if (!webhookToken) return ctr.status(401).print({ status: "FAILED", message: "No webhook token configured" });
const signature = ctr.headers.get("x-gitea-signature-256") || ctr.headers.get("x-hub-signature-256") || "";
const rawBody = await ctr.$body().text();
const expected = "sha256=" + createHmac("sha256", webhookToken.token).update(rawBody).digest("hex");
if (!timingSafeEqual(signature, expected)) {
log.warn({ userId, signature }, "Invalid webhook signature");
return ctr.status(401).print({ status: "FAILED", message: "Invalid signature" });
}
let payload: any;
try {
payload = JSON.parse(rawBody);
} catch {
return ctr.status(400).print({ status: "FAILED", message: "Invalid JSON payload" });
}
const event = ctr.headers.get("x-gitea-event") || "";
ctr.status(200).print({ status: "OK" });
setImmediate(() => handleWebhookAsync(user, event, payload).catch(e => log.error({ e }, "Webhook processing error")));
}
async function handleWebhookAsync(user: any, event: string, payload: any) {
if (event === "pull_request") {
await handlePullRequestEvent(user, payload);
} else if (event === "issue_comment") {
await handleIssueCommentEvent(user, payload);
}
}
async function handlePullRequestEvent(user: any, payload: any) {
const action = payload.action;
const pr = payload.pull_request;
const repo = payload.repository;
if (!pr || !repo) return;
const owner = repo.owner?.login || repo.full_name?.split("/")[0];
const repoName = repo.name;
const prNumber = pr.number;
const prTitle = pr.title || `PR #${prNumber}`;
const commitSha = pr.head?.sha || "";
const cloneUrl = pr.head?.repo?.clone_url || repo.clone_url;
const repoConfig = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
});
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 {}
}
return;
}
if (repoConfig.denyList.includes(pr.user?.login || "")) return;
if (action === "opened" || action === "reopened") {
let preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" },
});
if (preview && preview.status === "IGNORED") return;
if (!preview || preview.status === "STOPPED") {
preview = await prisma.preview.create({
data: {
repoConfigId: repoConfig.id,
prNumber,
prTitle,
commitSha,
status: "PROVISIONING",
port: repoConfig.port,
},
});
await prisma.job.create({
data: {
previewId: preview.id,
type: "DEPLOY",
status: "PENDING",
payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: true },
},
});
}
} else if (action === "synchronize") {
const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" },
});
if (!preview || preview.status === "IGNORED") return;
await prisma.preview.update({ where: { id: preview.id }, data: { lastActivityAt: new Date() } });
await prisma.job.create({
data: {
previewId: preview.id,
type: "DEPLOY",
status: "PENDING",
payload: { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy: preview.status === "STOPPED" },
},
});
} else if (action === "closed") {
const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" },
});
if (preview && preview.status !== "STOPPED" && preview.status !== "IGNORED") {
await prisma.job.create({
data: {
previewId: preview.id,
type: "STOP",
status: "PENDING",
payload: { reason: "PR closed" },
},
});
}
}
}
async function handleIssueCommentEvent(user: any, payload: any) {
const action = payload.action;
if (action !== "created") return;
const comment = payload.comment;
const issue = payload.issue;
const repo = payload.repository;
if (!comment || !issue || !repo || !issue.pull_request) return;
const body = comment.body || "";
if (!body.trimStart().startsWith("/pp ")) return;
const owner = repo.owner?.login || repo.full_name?.split("/")[0];
const repoName = repo.name;
const prNumber = issue.number;
if (user.giteaUsername && comment.user?.login === user.giteaUsername) return;
const repoConfig = await prisma.repoConfig.findFirst({
where: { repoOwner: owner, repoName, userId: user.id, isEnabled: true },
});
if (!repoConfig) return;
const commenter = comment.user?.login || "";
const prAuthor = issue.user?.login || "";
const isAllowed = commenter === prAuthor || (await isRepoAdmin(user, owner, repoName, commenter));
if (!isAllowed) return;
await prisma.preview.updateMany({
where: {
repoConfigId: repoConfig.id,
prNumber,
status: { in: ["RUNNING", "BUILDING", "FAILED", "PROVISIONING"] },
},
data: { lastActivityAt: new Date() },
});
const commandLine = body.trimStart().split("\n")[0].trim();
const command = commandLine.replace("/pp ", "").trim();
const preview = await prisma.preview.findFirst({
where: { repoConfigId: repoConfig.id, prNumber },
orderBy: { createdAt: "desc" },
});
const cloneUrl = repo.clone_url;
if (command === "rebuild") {
if (!preview || (preview.status !== "RUNNING" && preview.status !== "FAILED")) return;
await prisma.job.create({
data: {
previewId: preview.id,
type: "DEPLOY",
status: "PENDING",
payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: false },
},
});
} else if (command === "stop") {
if (!preview || preview.status === "STOPPED") return;
await prisma.job.create({
data: { previewId: preview.id, type: "STOP", status: "PENDING", payload: { reason: "Manual stop" } },
});
} else if (command === "start") {
if (!preview) {
const newPreview = await prisma.preview.create({
data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "PROVISIONING", port: repoConfig.port },
});
await prisma.job.create({
data: { previewId: newPreview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: "", prNumber, prTitle: issue.title, cloneUrl, isFirstDeploy: true } },
});
} else if (preview.status === "STOPPED" || preview.status === "IGNORED") {
await prisma.preview.update({ where: { id: preview.id }, data: { status: "PROVISIONING" } });
await prisma.job.create({
data: { previewId: preview.id, type: "DEPLOY", status: "PENDING", payload: { commitSha: preview.commitSha, prNumber, prTitle: preview.prTitle, cloneUrl, isFirstDeploy: true } },
});
}
} else if (command === "logs") {
if (!preview) return;
const lastLines = (preview.logs || "").split("\n").slice(-50).join("\n");
const logBody = `**PP Logs** (last 50 lines)\n\`\`\`\n${lastLines}\n\`\`\``;
await postComment(user, owner, repoName, prNumber, logBody);
} else if (command === "ignore") {
if (!preview) {
await prisma.preview.create({
data: { repoConfigId: repoConfig.id, prNumber, prTitle: issue.title, commitSha: "", status: "IGNORED", port: repoConfig.port },
});
} else {
await prisma.preview.update({ where: { id: preview.id }, data: { status: "IGNORED" } });
}
}
}
async function isRepoAdmin(user: any, owner: string, repo: string, username: string): Promise<boolean> {
try {
const { getRepoCollaboratorPermission } = await import("../services/gitea");
const permission = await getRepoCollaboratorPermission(user, owner, repo, username);
return permission === "owner" || permission === "admin";
} catch {
return false;
}
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
+466
View File
@@ -0,0 +1,466 @@
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { decrypt, encrypt } from "../lib/encryption";
import { getAdminSettings } from "../lib/adminSettings";
import { env } from "../lib/env";
import {
makeEc2Client,
generateAndImportKeyPair,
createPreviewSecurityGroup,
launchInstance,
waitForInstanceRunning,
terminateInstance,
deleteKeyPairAws,
deleteSecurityGroupAws,
} from "./ec2";
import { connectSsh, type SshSession } from "./ssh";
import {
buildPrCommentBody,
postComment,
updateComment,
} from "./gitea";
import type { Preview, RepoConfig, User } from "@prisma/client";
const log = createLogger("DEPLOY");
const activeSshSessions = new Map<number, SshSession>();
export function abortJobForPreview(previewId: number) {
const session = activeSshSessions.get(previewId);
if (session) {
session.abort();
activeSshSessions.delete(previewId);
}
}
export async function appendLog(previewId: number, text: string) {
const settings = await getAdminSettings();
const preview = await prisma.preview.findUnique({ where: { id: previewId } });
if (!preview) return;
let logs = (preview.logs || "") + text;
if (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const marker = "--- logs truncated ---\n";
while (Buffer.byteLength(logs, "utf8") > settings.logSizeLimitBytes) {
const nl = logs.indexOf("\n");
if (nl === -1) break;
logs = logs.slice(nl + 1);
}
logs = marker + logs;
}
await prisma.preview.update({ where: { id: previewId }, data: { logs } });
broadcastLogUpdate(previewId, text);
}
const logSubscribers = new Map<number, Set<(text: string) => void>>();
export function subscribeToLogs(previewId: number, cb: (text: string) => void): () => void {
if (!logSubscribers.has(previewId)) logSubscribers.set(previewId, new Set());
logSubscribers.get(previewId)!.add(cb);
return () => logSubscribers.get(previewId)?.delete(cb);
}
function broadcastLogUpdate(previewId: number, text: string) {
logSubscribers.get(previewId)?.forEach(cb => cb(text));
}
async function updateStatus(previewId: number, status: Preview["status"], extra: Partial<Preview> = {}) {
await prisma.preview.update({ where: { id: previewId }, data: { status, ...extra } });
}
async function updateGiteaComment(user: User, preview: Preview, repoConfig: RepoConfig, statusLine: string, lastLogLines?: string) {
const body = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: statusLine,
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
lastLogLines,
instanceIp: preview.instanceIp ?? undefined,
port: preview.port,
});
if (preview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, body);
} catch (e) {
log.warn({ e }, "Failed to update Gitea comment");
}
}
}
export async function runDeploy(jobId: number) {
const job = await prisma.job.findUnique({ where: { id: jobId }, include: { preview: { include: { repoConfig: { include: { user: true } } } } } });
if (!job || !job.preview) {
log.error({ jobId }, "Job or preview not found");
return;
}
await prisma.job.update({ where: { id: jobId }, data: { status: "RUNNING", startedAt: new Date() } });
const preview = job.preview as any;
const repoConfig: RepoConfig = preview.repoConfig;
const user: User = (preview.repoConfig as any).user;
const payload = job.payload as any;
const { commitSha, prNumber, prTitle, cloneUrl, isFirstDeploy } = payload;
try {
if (isFirstDeploy) {
await firstDeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle, cloneUrl);
} else {
await redeploy(jobId, preview, repoConfig, user, commitSha, prNumber, prTitle);
}
await prisma.job.update({ where: { id: jobId }, data: { status: "DONE", finishedAt: new Date() } });
} catch (e: any) {
if (e.message === "ABORTED") {
log.info({ jobId, previewId: preview.id }, "Job aborted");
await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: "Aborted by newer deploy", finishedAt: new Date() } });
return;
}
log.error({ e, jobId, previewId: preview.id }, "Deploy failed");
await prisma.job.update({ where: { id: jobId }, data: { status: "FAILED", error: e.message, finishedAt: new Date() } });
const freshPreview = await prisma.preview.findUnique({ where: { id: preview.id } });
if (!freshPreview) return;
const lastLines = (freshPreview.logs || "").split("\n").slice(-10).join("\n");
await updateStatus(preview.id, "FAILED");
const failBody = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: freshPreview.prNumber,
status: `🔴 Failed — last log lines:\n\`\`\`\n${lastLines}\n\`\`\``,
commitSha: freshPreview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
if (freshPreview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, freshPreview.giteaCommentId, failBody);
} catch {}
}
}
}
async function firstDeploy(
jobId: number,
preview: Preview & { repoConfig: RepoConfig },
repoConfig: RepoConfig,
user: User,
commitSha: string,
prNumber: number,
prTitle: string,
cloneUrl: string,
) {
const settings = await getAdminSettings();
const ec2 = makeEc2Client(user);
const previewId = preview.id;
const activeCount = await prisma.preview.count({
where: {
repoConfig: { userId: user.id },
status: { in: ["PROVISIONING", "BUILDING", "RUNNING"] },
id: { not: previewId },
},
});
if (activeCount >= settings.maxConcurrentInstancesPerUser) {
const body = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber,
status: `🔴 Cannot provision preview — concurrent instance limit (${settings.maxConcurrentInstancesPerUser}) reached.`,
commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
const commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, body);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
throw new Error("Concurrent instance limit reached");
}
await updateStatus(previewId, "PROVISIONING", { commitSha, prNumber, prTitle });
const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: "🟡 Provisioning EC2 instance...",
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
});
let commentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: commentId } });
checkAbort(previewId);
const keyName = `pp-preview-${previewId}`;
const { privateKey } = await generateAndImportKeyPair(ec2, keyName);
const encPrivateKey = encrypt(privateKey);
await prisma.preview.update({ where: { id: previewId }, data: { sshPrivateKey: encPrivateKey, sshKeyName: keyName } });
checkAbort(previewId);
const sgName = `pp-preview-${previewId}`;
const securityGroupId = await createPreviewSecurityGroup(ec2, sgName, repoConfig.port);
checkAbort(previewId);
const instanceId = await launchInstance({
ec2, region: user.awsRegion!,
instanceType: repoConfig.instanceType,
keyName,
securityGroupId,
tags: {
"pp:managed": "true",
"pp:userId": String(user.id),
"pp:repo": `${repoConfig.repoOwner}/${repoConfig.repoName}`,
"pp:prNumber": String(prNumber),
"pp:previewId": String(previewId),
},
});
await prisma.preview.update({ where: { id: previewId }, data: { instanceId } });
await appendLog(previewId, `[PP] EC2 instance ${instanceId} launched. Waiting for it to be running...\n`);
checkAbort(previewId);
const instanceIp = await waitForInstanceRunning(ec2, instanceId);
await prisma.preview.update({ where: { id: previewId }, data: { instanceIp, port: repoConfig.port } });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟡 Building... (EC2 ready at ${instanceIp})`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
await appendLog(previewId, `[PP] Instance running at ${instanceIp}. Waiting for SSH...\n`);
checkAbort(previewId);
const sshSession = await connectSsh(instanceIp, privateKey, 300_000);
activeSshSessions.set(previewId, sshSession);
try {
if (repoConfig.aptPackages.length > 0) {
await runSshStep(previewId, sshSession, `sudo apt-get install -y ${repoConfig.aptPackages.join(" ")}`);
}
const giteaPat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
const authCloneUrl = cloneUrl.replace("https://", `https://${user.giteaUsername}:${giteaPat}@`);
await runSshStep(previewId, sshSession, `git clone ${authCloneUrl} /opt/app`);
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr`);
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, true);
await updateStatus(previewId, "RUNNING", { commitSha, instanceIp, port: repoConfig.port, lastActivityAt: new Date() });
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, commentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function redeploy(
jobId: number,
preview: Preview & { repoConfig: RepoConfig },
repoConfig: RepoConfig,
user: User,
commitSha: string,
prNumber: number,
prTitle: string,
) {
const previewId = preview.id;
const instanceIp = preview.instanceIp!;
const privateKey = decrypt(preview.sshPrivateKey!);
const sshSession = await connectSsh(instanceIp, privateKey, 30_000);
activeSshSessions.set(previewId, sshSession);
await appendLog(previewId, `\n--- Redeploy: ${commitSha} ---\n`);
const freshPreview = await prisma.preview.findUnique({ where: { id: previewId } });
const commentBody = buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟡 Building... (EC2 at ${instanceIp})`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
});
const newCommentId = await postComment(user, repoConfig.repoOwner, repoConfig.repoName, prNumber, commentBody);
await prisma.preview.update({ where: { id: previewId }, data: { giteaCommentId: newCommentId, commitSha } });
try {
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} down 2>&1 || true`);
} else if (freshPreview?.pid) {
await runSshStep(previewId, sshSession, `kill ${freshPreview.pid} 2>/dev/null || true; sleep 5; kill -9 ${freshPreview.pid} 2>/dev/null || true`);
}
await runSshStep(previewId, sshSession, `cd /opt/app && git fetch origin pull/${prNumber}/head:pp-pr && git checkout pp-pr && git reset --hard FETCH_HEAD`);
await updateStatus(previewId, "BUILDING");
await setupAndBuild(previewId, sshSession, repoConfig, preview, commitSha, false);
await updateStatus(previewId, "RUNNING", { commitSha, lastActivityAt: new Date() });
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, newCommentId,
buildPrCommentBody({
owner: repoConfig.repoOwner, repo: repoConfig.repoName, prNumber,
status: `🟢 Live at http://${instanceIp}:${repoConfig.port}`,
commitSha, updatedAt: new Date(), ppBaseUrl: env.PP_BASE_URL,
})
);
} finally {
sshSession.close();
activeSshSessions.delete(previewId);
}
}
async function setupAndBuild(
previewId: number,
sshSession: SshSession,
repoConfig: RepoConfig,
preview: Preview,
commitSha: string,
isFirstProvision: boolean,
) {
await detectAndUseNode(previewId, sshSession);
const envVars = repoConfig.envVars as Record<string, string>;
const envContent = Object.entries(envVars).map(([k, v]) => `${k}=${v}`).join("\n");
await runSshStep(previewId, sshSession, `cat > /opt/app/.env << 'PPEOF'\n${envContent}\nPPEOF`);
if (isFirstProvision) {
for (const cmd of repoConfig.setupCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
}
await updateStatus(previewId, "BUILDING");
if (repoConfig.useDockerCompose) {
const composePath = repoConfig.composeFilePath || "docker-compose.yml";
await runSshStep(previewId, sshSession, `cd /opt/app && docker compose -f ${composePath} up -d --build --force-recreate 2>&1`);
} else {
for (const cmd of repoConfig.buildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
for (const cmd of repoConfig.postBuildCommands) {
await runSshStep(previewId, sshSession, `cd /opt/app && ${cmd}`);
}
if (repoConfig.runCommand) {
const res = await runSshStep(previewId, sshSession,
`cd /opt/app && nohup ${repoConfig.runCommand} > /opt/app/pp.log 2>&1 & echo $!`
);
const pid = parseInt(res.stdout.trim(), 10);
if (!isNaN(pid)) {
await prisma.preview.update({ where: { id: previewId }, data: { pid } });
}
}
}
}
async function detectAndUseNode(previewId: number, sshSession: SshSession) {
const nvmSource = `export NVM_DIR="/root/.nvm" && source "$NVM_DIR/nvm.sh"`;
const res = await runSshStep(previewId, sshSession, `${nvmSource} && [ -f /opt/app/.nvmrc ] && nvm install && nvm use || nvm use default 2>&1`, false);
}
async function runSshStep(previewId: number, sshSession: SshSession, command: string, throwOnFail = true) {
checkAbortSession(sshSession);
await appendLog(previewId, `$ ${command}\n`);
const res = await sshSession.exec(command);
if (res.stdout) await appendLog(previewId, res.stdout);
if (res.stderr) await appendLog(previewId, res.stderr);
if (throwOnFail && res.code !== 0) {
throw new Error(`Command failed with exit code ${res.code}: ${command}`);
}
return res;
}
function checkAbortSession(session: SshSession) {
if (session.aborted) throw new Error("ABORTED");
}
const abortSignals = new Set<number>();
export function signalAbort(previewId: number) {
abortSignals.add(previewId);
abortJobForPreview(previewId);
}
function checkAbort(previewId: number) {
if (abortSignals.has(previewId)) {
abortSignals.delete(previewId);
throw new Error("ABORTED");
}
}
export async function stopPreview(previewId: number, reason: "STOPPED" | "FAILED" = "STOPPED") {
const preview = await prisma.preview.findUnique({
where: { id: previewId },
include: { repoConfig: { include: { user: true } } },
});
if (!preview) return;
const user = (preview.repoConfig as any).user as User;
const repoConfig = preview.repoConfig;
if (preview.instanceId) {
const ec2 = makeEc2Client(user);
try {
await deleteKeyPairAws(ec2, `pp-preview-${previewId}`);
} catch {}
try {
await deleteSecurityGroupAws(ec2, `pp-preview-${previewId}`);
} catch {}
try {
await terminateInstance(ec2, preview.instanceId);
} catch {}
}
await prisma.preview.update({
where: { id: previewId },
data: {
status: reason,
stoppedAt: new Date(),
sshPrivateKey: null,
sshKeyName: null,
instanceId: null,
},
});
const stoppedBody = buildPrCommentBody({
owner: repoConfig.repoOwner,
repo: repoConfig.repoName,
prNumber: preview.prNumber,
status: "⚫ Stopped (inactivity timeout / PR closed / manual stop)",
commitSha: preview.commitSha,
updatedAt: new Date(),
ppBaseUrl: env.PP_BASE_URL,
});
if (preview.giteaCommentId) {
try {
await updateComment(user, repoConfig.repoOwner, repoConfig.repoName, preview.giteaCommentId, stoppedBody);
} catch {}
}
}
+242
View File
@@ -0,0 +1,242 @@
import {
EC2Client,
RunInstancesCommand,
TerminateInstancesCommand,
DescribeInstancesCommand,
CreateSecurityGroupCommand,
DeleteSecurityGroupCommand,
AuthorizeSecurityGroupIngressCommand,
DescribeSecurityGroupsCommand,
ImportKeyPairCommand,
DeleteKeyPairCommand,
CreateTagsCommand,
} from "@aws-sdk/client-ec2";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
import { generateKeyPairSync } from "crypto";
import { createLogger } from "../lib/logger";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";
const log = createLogger("EC2");
const UBUNTU_22_04_AMI: Record<string, string> = {
"us-east-1": "ami-0e86e20dae9224db8",
"us-east-2": "ami-0a0d9cf81c479446a",
"us-west-1": "ami-05c969369880fa2c2",
"us-west-2": "ami-03f8acd418785369b",
"eu-west-1": "ami-0694d931cee176e7d",
"eu-west-2": "ami-0f3d9639a5674d559",
"eu-west-3": "ami-022e307f4b9e39f45",
"eu-central-1": "ami-0faab6bdbac9486fb",
"ap-southeast-1": "ami-0823c236601fef765",
"ap-southeast-2": "ami-07620139298af599e",
"ap-northeast-1": "ami-0b7546e839d7ace12",
"ap-northeast-2": "ami-042e76978adeb8c48",
"ap-south-1": "ami-076e3a557efe1aa9c",
"sa-east-1": "ami-0eed58016fbe42de3",
"ca-central-1": "ami-024f768de9e73d4f4",
"eu-north-1": "ami-00381a880aa48c6c6",
"me-south-1": "ami-09574f34b8dcd2eac",
"af-south-1": "ami-08fdcf06b39fe83ec",
};
export function makeEc2Client(user: User): EC2Client {
const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : "";
return new EC2Client({
region: user.awsRegion!,
credentials: { accessKeyId, secretAccessKey },
});
}
export function makeStsClient(user: User): STSClient {
const accessKeyId = user.awsAccessKeyId ? decrypt(user.awsAccessKeyId) : "";
const secretAccessKey = user.awsSecretAccessKey ? decrypt(user.awsSecretAccessKey) : "";
return new STSClient({
region: user.awsRegion!,
credentials: { accessKeyId, secretAccessKey },
});
}
export async function validateAwsCredentials(user: User): Promise<{ success: boolean; arn?: string; error?: string }> {
try {
const sts = makeStsClient(user);
const res = await sts.send(new GetCallerIdentityCommand({}));
return { success: true, arn: res.Arn };
} catch (e: any) {
return { success: false, error: e.message };
}
}
export function generateSshKeyPair(): { privateKey: string; publicKey: string } {
const { privateKey, publicKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
const pubKeyOpenSsh = rsaPemToOpenSsh(publicKey);
return { privateKey, publicKey: pubKeyOpenSsh };
}
function rsaPemToOpenSsh(pem: string): string {
const { publicKeyEncoding } = generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs8", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
void publicKeyEncoding;
const der = Buffer.from(
pem.replace(/-----BEGIN RSA PUBLIC KEY-----/, "")
.replace(/-----END RSA PUBLIC KEY-----/, "")
.replace(/\n/g, ""),
"base64"
);
const type = Buffer.from("ssh-rsa");
function encodeBuffer(buf: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(buf.length, 0);
return Buffer.concat([len, buf]);
}
const typeEncoded = encodeBuffer(type);
const rsaKeyData = der;
const base64Key = Buffer.concat([typeEncoded, rsaKeyData]).toString("base64");
return `ssh-rsa ${base64Key} pp-generated`;
}
export async function generateAndImportKeyPair(ec2: EC2Client, keyName: string): Promise<{ privateKey: string }> {
const { privateKey, publicKey } = generateSshKeyPair();
await ec2.send(new ImportKeyPairCommand({
KeyName: keyName,
PublicKeyMaterial: Buffer.from(publicKey),
}));
return { privateKey };
}
export async function createPreviewSecurityGroup(ec2: EC2Client, groupName: string, port: number): Promise<string> {
const describe = await ec2.send(new DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }],
}));
if (describe.SecurityGroups && describe.SecurityGroups.length > 0) {
return describe.SecurityGroups[0].GroupId!;
}
const res = await ec2.send(new CreateSecurityGroupCommand({
GroupName: groupName,
Description: `PP Preview security group: ${groupName}`,
}));
const groupId = res.GroupId!;
await ec2.send(new AuthorizeSecurityGroupIngressCommand({
GroupId: groupId,
IpPermissions: [
{
IpProtocol: "tcp",
FromPort: 22,
ToPort: 22,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
{
IpProtocol: "tcp",
FromPort: port,
ToPort: port,
IpRanges: [{ CidrIp: "0.0.0.0/0" }],
},
],
}));
return groupId;
}
const BOOTSTRAP_SCRIPT = `#!/bin/bash
set -e
apt-get update -y
apt-get install -y curl git unzip build-essential
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
apt-get install -y docker-compose-plugin
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="/root/.nvm"
source "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default lts/*
`;
export async function launchInstance(opts: {
ec2: EC2Client;
region: string;
instanceType: string;
keyName: string;
securityGroupId: string;
tags: Record<string, string>;
}): Promise<string> {
const ami = UBUNTU_22_04_AMI[opts.region] ?? UBUNTU_22_04_AMI["us-east-1"];
const tagSpecs = Object.entries(opts.tags).map(([k, v]) => ({ Key: k, Value: v }));
tagSpecs.push({ Key: "Name", Value: `pp-preview-${opts.tags["pp:previewId"]}` });
const res = await opts.ec2.send(new RunInstancesCommand({
ImageId: ami,
InstanceType: opts.instanceType as any,
MinCount: 1,
MaxCount: 1,
KeyName: opts.keyName,
SecurityGroupIds: [opts.securityGroupId],
UserData: Buffer.from(BOOTSTRAP_SCRIPT).toString("base64"),
TagSpecifications: [
{ ResourceType: "instance", Tags: tagSpecs },
],
}));
return res.Instances![0].InstanceId!;
}
export async function waitForInstanceRunning(ec2: EC2Client, instanceId: string, maxWaitMs = 300_000): Promise<string> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const res = await ec2.send(new DescribeInstancesCommand({
InstanceIds: [instanceId],
}));
const inst = res.Reservations?.[0]?.Instances?.[0];
if (inst?.State?.Name === "running" && inst.PublicIpAddress) {
return inst.PublicIpAddress;
}
await sleep(5000);
}
throw new Error(`Instance ${instanceId} did not reach running state within timeout`);
}
export async function terminateInstance(ec2: EC2Client, instanceId: string): Promise<void> {
await ec2.send(new TerminateInstancesCommand({ InstanceIds: [instanceId] }));
}
export async function deleteKeyPairAws(ec2: EC2Client, keyName: string): Promise<void> {
try {
await ec2.send(new DeleteKeyPairCommand({ KeyName: keyName }));
} catch (e) {
log.warn({ e, keyName }, "Failed to delete key pair");
}
}
export async function deleteSecurityGroupAws(ec2: EC2Client, groupName: string): Promise<void> {
try {
const describe = await ec2.send(new DescribeSecurityGroupsCommand({
Filters: [{ Name: "group-name", Values: [groupName] }],
}));
const groupId = describe.SecurityGroups?.[0]?.GroupId;
if (groupId) {
await ec2.send(new DeleteSecurityGroupCommand({ GroupId: groupId }));
}
} catch (e) {
log.warn({ e, groupName }, "Failed to delete security group");
}
}
export async function describeAllManagedInstances(ec2: EC2Client): Promise<any[]> {
const res = await ec2.send(new DescribeInstancesCommand({
Filters: [{ Name: "tag:pp:managed", Values: ["true"] }, { Name: "instance-state-name", Values: ["running", "pending", "stopping", "stopped"] }],
}));
return (res.Reservations ?? []).flatMap(r => r.Instances ?? []);
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
+133
View File
@@ -0,0 +1,133 @@
import axios from "axios";
import { decrypt } from "../lib/encryption";
import type { User } from "@prisma/client";
export function giteaApi(user: User) {
const pat = user.giteaPAT ? decrypt(user.giteaPAT) : "";
return axios.create({
baseURL: `${user.giteaInstanceUrl}/api/v1`,
headers: {
Authorization: `token ${pat}`,
"Content-Type": "application/json",
},
timeout: 15000,
});
}
export async function validateGiteaUrl(url: string): Promise<{ success: boolean; version?: string; error?: string }> {
try {
const res = await axios.get(`${url}/api/v1/version`, { timeout: 10000 });
return { success: true, version: res.data.version };
} catch (e: any) {
return { success: false, error: e.message };
}
}
export async function fetchUserRepos(user: User): Promise<any[]> {
const api = giteaApi(user);
const repos: any[] = [];
let page = 1;
while (true) {
const res = await api.get(`/repos/search?limit=50&page=${page}`);
const data = res.data?.data ?? [];
if (data.length === 0) break;
repos.push(...data);
if (data.length < 50) break;
page++;
}
return repos;
}
export async function registerWebhook(user: User, owner: string, repo: string, webhookUrl: string, secret: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/hooks`, {
type: "gitea",
config: {
url: webhookUrl,
secret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
active: true,
});
return res.data.id;
}
export async function deleteWebhook(user: User, owner: string, repo: string, hookId: string): Promise<void> {
const api = giteaApi(user);
await api.delete(`/repos/${owner}/${repo}/hooks/${hookId}`);
}
export async function updateWebhookSecret(user: User, owner: string, repo: string, hookId: string, webhookUrl: string, newSecret: string): Promise<void> {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/hooks/${hookId}`, {
config: {
url: webhookUrl,
secret: newSecret,
content_type: "json",
},
events: ["pull_request", "issue_comment"],
active: true,
});
}
export async function postComment(user: User, owner: string, repo: string, issueNumber: number, body: string): Promise<number> {
const api = giteaApi(user);
const res = await api.post(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { body });
return res.data.id;
}
export async function updateComment(user: User, owner: string, repo: string, commentId: number, body: string): Promise<void> {
const api = giteaApi(user);
await api.patch(`/repos/${owner}/${repo}/issues/comments/${commentId}`, { body });
}
export async function checkUserPermission(user: User, owner: string, repo: string, username: string): Promise<boolean> {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}`);
return res.status === 204;
} catch {
return false;
}
}
export async function getRepoCollaboratorPermission(user: User, owner: string, repo: string, username: string): Promise<string | null> {
try {
const api = giteaApi(user);
const res = await api.get(`/repos/${owner}/${repo}/collaborators/${username}/permission`);
return res.data?.permission ?? null;
} catch {
return null;
}
}
export function buildPrCommentBody(opts: {
owner: string;
repo: string;
prNumber: number;
status: string;
commitSha: string;
updatedAt: Date;
ppBaseUrl: string;
lastLogLines?: string;
instanceIp?: string;
port?: number;
}): string {
const { owner, repo, prNumber, status, commitSha, updatedAt, ppBaseUrl, lastLogLines, instanceIp, port } = opts;
const ts = updatedAt.toISOString().replace("T", " ").slice(0, 19) + " UTC";
let statusLine = status;
if (lastLogLines) {
statusLine += `\n\n\`\`\`\n${lastLogLines}\n\`\`\``;
}
return `## 🚀 PR Preview — \`${owner}/${repo}\` #${prNumber}
**Status:** ${statusLine}
**Commit:** \`${commitSha.slice(0, 8)}\`
**Updated:** ${ts}
---
_Powered by [PR Previews](${ppBaseUrl})_`;
}
+90
View File
@@ -0,0 +1,90 @@
import { Client } from "ssh2";
import { createLogger } from "../lib/logger";
const log = createLogger("SSH");
export interface SshSession {
exec(command: string): Promise<{ stdout: string; stderr: string; code: number }>;
close(): void;
aborted: boolean;
abort(): void;
}
export async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise<SshSession> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const conn = await tryConnect(host, privateKey, 10000);
let aborted = false;
return {
get aborted() { return aborted; },
abort() {
aborted = true;
try { conn.end(); } catch {}
},
async exec(command: string) {
if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command);
},
close() {
try { conn.end(); } catch {}
},
};
} catch (e: any) {
if (Date.now() - start > maxWaitMs) throw e;
log.debug({ host, error: e.message }, "SSH connect retry");
await sleep(5000);
}
}
throw new Error(`Could not SSH into ${host} within timeout`);
}
function tryConnect(host: string, privateKey: string, timeoutMs: number): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timer = setTimeout(() => {
conn.end();
reject(new Error(`SSH connection to ${host} timed out`));
}, timeoutMs);
conn.on("ready", () => {
clearTimeout(timer);
resolve(conn);
});
conn.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
conn.connect({
host,
port: 22,
username: "ubuntu",
privateKey,
readyTimeout: timeoutMs,
algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521"],
},
});
});
}
function execOnConn(conn: Client, command: string): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
conn.exec(command, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
stream.on("data", (d: Buffer) => { stdout += d.toString(); });
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
stream.on("close", (code: number) => {
resolve({ stdout, stderr, code: code ?? 0 });
});
});
});
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
+76
View File
@@ -0,0 +1,76 @@
import cron from "node-cron";
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { getAdminSettings } from "../lib/adminSettings";
const log = createLogger("CRON");
export function startCronWorkers() {
// Inactivity check every 30 minutes
cron.schedule("*/30 * * * *", async () => {
try {
await checkInactivity();
} catch (e) {
log.error({ e }, "Inactivity check error");
}
});
// Daily cleanup
cron.schedule("0 3 * * *", async () => {
try {
await dailyCleanup();
} catch (e) {
log.error({ e }, "Daily cleanup error");
}
});
log.info("Cron workers started");
}
async function checkInactivity() {
const settings = await getAdminSettings();
const now = new Date();
const running = await prisma.preview.findMany({
where: { status: "RUNNING" },
});
for (const preview of running) {
const inactivityMs = settings.maxConcurrentInstancesPerUser; // will use actual inactivityHours from repoConfig
const repoConfig = await prisma.repoConfig.findUnique({ where: { id: preview.repoConfigId } });
if (!repoConfig) continue;
const deadline = new Date(preview.lastActivityAt.getTime() + repoConfig.inactivityHours * 3600 * 1000);
if (now >= deadline) {
log.info({ previewId: preview.id }, "Preview inactive, enqueuing INACTIVITY_STOP");
await prisma.job.create({
data: {
previewId: preview.id,
type: "INACTIVITY_STOP",
status: "PENDING",
payload: {},
},
});
}
}
}
async function dailyCleanup() {
const settings = await getAdminSettings();
const cutoff = new Date(Date.now() - settings.previewRetentionDays * 86400 * 1000);
const old = await prisma.preview.findMany({
where: {
status: { in: ["STOPPED", "FAILED"] },
stoppedAt: { lt: cutoff },
},
select: { id: true },
});
for (const { id } of old) {
await prisma.job.deleteMany({ where: { previewId: id } });
await prisma.preview.delete({ where: { id } });
}
if (old.length > 0) log.info({ count: old.length }, "Cleaned up old previews");
}
+99
View File
@@ -0,0 +1,99 @@
import { prisma } from "../lib/db";
import { createLogger } from "../lib/logger";
import { runDeploy, stopPreview, signalAbort } from "../services/deploy";
const log = createLogger("JOB_WORKER");
let running = false;
export async function startJobWorker() {
if (running) return;
running = true;
log.info("Job worker started");
await resetStuckJobs();
pollLoop();
}
async function resetStuckJobs() {
const count = await prisma.job.updateMany({
where: { status: "RUNNING" },
data: { status: "PENDING", startedAt: null },
});
if (count.count > 0) log.info({ count: count.count }, "Reset stuck running jobs to PENDING");
}
async function pollLoop() {
while (running) {
try {
await processPendingJobs();
} catch (e) {
log.error({ e }, "Job worker poll error");
}
await sleep(1000);
}
}
const activePreviewJobs = new Map<number, number>();
async function processPendingJobs() {
const pending = await prisma.job.findMany({
where: { status: "PENDING" },
orderBy: { createdAt: "asc" },
take: 20,
});
for (const job of pending) {
const previewId = job.previewId;
if (!previewId) continue;
if (activePreviewJobs.has(previewId)) {
const existingJobId = activePreviewJobs.get(previewId)!;
if (job.type === "DEPLOY") {
log.info({ previewId, newJobId: job.id, abortingJobId: existingJobId }, "New deploy cancels existing");
signalAbort(previewId);
await sleep(500);
} else {
continue;
}
}
activePreviewJobs.set(previewId, job.id);
processJob(job).finally(() => {
if (activePreviewJobs.get(previewId) === job.id) {
activePreviewJobs.delete(previewId);
}
});
}
}
async function processJob(job: { id: number; type: string; previewId: number | null; payload: any }) {
log.info({ jobId: job.id, type: job.type, previewId: job.previewId }, "Processing job");
try {
if (job.type === "DEPLOY") {
await runDeploy(job.id);
} else if (job.type === "STOP" || job.type === "INACTIVITY_STOP") {
if (job.previewId) {
await prisma.job.update({ where: { id: job.id }, data: { status: "RUNNING", startedAt: new Date() } });
await stopPreview(job.previewId);
await prisma.job.update({ where: { id: job.id }, data: { status: "DONE", finishedAt: new Date() } });
}
}
} catch (e: any) {
log.error({ e, jobId: job.id }, "Job processing error");
await prisma.job.update({
where: { id: job.id },
data: { status: "FAILED", error: e.message, finishedAt: new Date() },
}).catch(() => {});
}
}
export function stopJobWorker() {
running = false;
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}