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,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 } } });
|
||||
}
|
||||
Reference in New Issue
Block a user