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:
@@ -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"));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
errorFormat: "pretty",
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" } });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 } } });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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})_`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user