Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,262 changes: 1,168 additions & 1,094 deletions apps/webapp/app/env.server.ts

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions apps/webapp/app/routes/_app.github.callback/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { validateGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { linkGitHubAppInstallation, updateGitHubAppInstallation } from "~/services/gitHub.server";
import { logger } from "~/services/logger.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { tryCatch } from "@trigger.dev/core";
import { $replica } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { sanitizeRedirectPath } from "~/utils";

const QuerySchema = z.discriminatedUnion("setup_action", [
z.object({
setup_action: z.literal("install"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("update"),
installation_id: z.coerce.number(),
state: z.string(),
}),
z.object({
setup_action: z.literal("request"),
state: z.string(),
}),
]);

export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const queryParams = Object.fromEntries(url.searchParams);
const cookieHeader = request.headers.get("Cookie");

const result = QuerySchema.safeParse(queryParams);

if (!result.success) {
logger.warn("GitHub App callback with invalid params", {
queryParams,
});
return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
}

const callbackData = result.data;

const sessionResult = await validateGitHubAppInstallSession(cookieHeader, callbackData.state);

if (!sessionResult.valid) {
logger.error("GitHub App callback with invalid session", {
callbackData,
error: sessionResult.error,
});

return redirectWithErrorMessage("/", request, "Failed to install GitHub App");
}

const { organizationId, redirectTo: unsafeRedirectTo } = sessionResult;
const redirectTo = sanitizeRedirectPath(unsafeRedirectTo);

const user = await requireUser(request);
const org = await $replica.organization.findFirst({
where: { id: organizationId, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});

if (!org) {
// the secure cookie approach should already protect against this
// just an additional check
logger.error("GitHub app installation attempt on unauthenticated org", {
userId: user.id,
organizationId,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}

switch (callbackData.setup_action) {
case "install": {
const [error] = await tryCatch(
linkGitHubAppInstallation(callbackData.installation_id, organizationId)
);

if (error) {
logger.error("Failed to link GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}

return redirectWithSuccessMessage(redirectTo, request, "GitHub App installed successfully");
}

case "update": {
const [error] = await tryCatch(updateGitHubAppInstallation(callbackData.installation_id));

if (error) {
logger.error("Failed to update GitHub App installation", {
error,
});
return redirectWithErrorMessage(redirectTo, request, "Failed to update GitHub App");
}

return redirectWithSuccessMessage(redirectTo, request, "GitHub App updated successfully");
}

case "request": {
// This happens when a non-admin user requests installation
// The installation_id won't be available until an admin approves
logger.info("GitHub App installation requested, awaiting approval", {
callbackData,
});

return redirectWithSuccessMessage(redirectTo, request, "GitHub App installation requested");
}

default:
callbackData satisfies never;
return redirectWithErrorMessage(redirectTo, request, "Failed to install GitHub App");
}
}
52 changes: 52 additions & 0 deletions apps/webapp/app/routes/_app.github.install/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "remix-typedjson";
import { z } from "zod";
import { $replica } from "~/db.server";
import { createGitHubAppInstallSession } from "~/services/gitHubSession.server";
import { requireUser } from "~/services/session.server";
import { newOrganizationPath } from "~/utils/pathBuilder";
import { logger } from "~/services/logger.server";
import { sanitizeRedirectPath } from "~/utils";

const QuerySchema = z.object({
org_slug: z.string(),
redirect_to: z.string().refine((value) => value === sanitizeRedirectPath(value), {
message: "Invalid redirect path",
}),
});

export const loader = async ({ request }: LoaderFunctionArgs) => {
const searchParams = new URL(request.url).searchParams;
const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams));

if (!parsed.success) {
logger.warn("GitHub App installation redirect with invalid params", {
searchParams,
error: parsed.error,
});
throw redirect("/");
}

const { org_slug, redirect_to } = parsed.data;
const user = await requireUser(request);

const org = await $replica.organization.findFirst({
where: { slug: org_slug, members: { some: { userId: user.id } }, deletedAt: null },
orderBy: { createdAt: "desc" },
select: {
id: true,
},
});

if (!org) {
throw redirect(newOrganizationPath());
}

const { url, cookieHeader } = await createGitHubAppInstallSession(org.id, redirect_to);

return redirect(url, {
headers: {
"Set-Cookie": cookieHeader,
},
});
};
3 changes: 2 additions & 1 deletion apps/webapp/app/routes/auth.github.callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { getSession, redirectWithErrorMessage } from "~/models/message.server";
import { authenticator } from "~/services/auth.server";
import { commitSession } from "~/services/sessionStorage.server";
import { redirectCookie } from "./auth.github";
import { sanitizeRedirectPath } from "~/utils";

export let loader: LoaderFunction = async ({ request }) => {
const cookie = request.headers.get("Cookie");
const redirectValue = await redirectCookie.parse(cookie);
const redirectTo = redirectValue ?? "/";
const redirectTo = sanitizeRedirectPath(redirectValue);

const auth = await authenticator.authenticate("github", request, {
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
Expand Down
4 changes: 1 addition & 3 deletions apps/webapp/app/routes/auth.github.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { createCookie } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
import { authenticator } from "~/services/auth.server";

export let loader: LoaderFunction = () => redirect("/login");
Expand Down
135 changes: 135 additions & 0 deletions apps/webapp/app/services/gitHub.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { App, type Octokit } from "octokit";
import { env } from "../env.server";
import { prisma } from "~/db.server";
import { logger } from "./logger.server";

export const githubApp =
env.GITHUB_APP_ENABLED === "1"
? new App({
appId: env.GITHUB_APP_ID,
privateKey: env.GITHUB_APP_PRIVATE_KEY,
webhooks: {
secret: env.GITHUB_APP_WEBHOOK_SECRET,
},
})
: null;

/**
* Links a GitHub App installation to a Trigger organization
*/
export async function linkGitHubAppInstallation(
installationId: number,
organizationId: string
): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}

const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});

const repositories = await fetchInstallationRepositories(octokit, installationId);

const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";

await prisma.githubAppInstallation.create({
data: {
appInstallationId: installationId,
organizationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
repositorySelection,
repositories: {
create: repositories,
},
},
});
}

/**
* Links a GitHub App installation to a Trigger organization
*/
export async function updateGitHubAppInstallation(installationId: number): Promise<void> {
if (!githubApp) {
throw new Error("GitHub App is not enabled");
}

const octokit = await githubApp.getInstallationOctokit(installationId);
const { data: installation } = await octokit.rest.apps.getInstallation({
installation_id: installationId,
});

const existingInstallation = await prisma.githubAppInstallation.findFirst({
where: { appInstallationId: installationId },
});

if (!existingInstallation) {
throw new Error("GitHub App installation not found");
}

const repositorySelection = installation.repository_selection === "all" ? "ALL" : "SELECTED";

// repos are updated asynchronously via webhook events
await prisma.githubAppInstallation.update({
where: { id: existingInstallation?.id },
data: {
appInstallationId: installationId,
targetId: installation.target_id,
targetType: installation.target_type,
accountHandle: installation.account
? "login" in installation.account
? installation.account.login
: "slug" in installation.account
? installation.account.slug
: "-"
: "-",
permissions: installation.permissions,
suspendedAt: existingInstallation?.suspendedAt,
repositorySelection,
},
});
}

async function fetchInstallationRepositories(octokit: Octokit, installationId: number) {
const iterator = octokit.paginate.iterator(octokit.rest.apps.listReposAccessibleToInstallation, {
installation_id: installationId,
per_page: 100,
});

const allRepos = [];
const maxPages = 3;
let pageCount = 0;

for await (const { data } of iterator) {
pageCount++;
allRepos.push(...data);

if (maxPages && pageCount >= maxPages) {
logger.warn("GitHub installation repository fetch truncated", {
installationId,
maxPages,
totalReposFetched: allRepos.length,
});
break;
}
}

return allRepos.map((repo) => ({
githubId: repo.id,
name: repo.name,
fullName: repo.full_name,
htmlUrl: repo.html_url,
private: repo.private,
defaultBranch: repo.default_branch,
}));
}
Loading
Loading