diff --git a/components/dashboard/src/projects/NewProject.tsx b/components/dashboard/src/projects/NewProject.tsx
index 029318414d3e40..238b32b5b5a8db 100644
--- a/components/dashboard/src/projects/NewProject.tsx
+++ b/components/dashboard/src/projects/NewProject.tsx
@@ -525,8 +525,7 @@ function GitProviders(props: {
});
}
- // for now we exclude GitHub Enterprise
- const filteredProviders = () => props.authProviders.filter(p => p.host === "github.com" || p.host === "bitbucket.org" || p.authProviderType === "GitLab");
+ const filteredProviders = () => props.authProviders.filter(p => p.authProviderType === "GitHub" || p.host === "bitbucket.org" || p.authProviderType === "GitLab");
return (
diff --git a/components/dashboard/src/settings/Integrations.tsx b/components/dashboard/src/settings/Integrations.tsx
index 0cb9a06b5060b7..f6ea5578ed4607 100644
--- a/components/dashboard/src/settings/Integrations.tsx
+++ b/components/dashboard/src/settings/Integrations.tsx
@@ -571,10 +571,10 @@ export function GitIntegrationModal(props: ({
}
const updateClientId = (value: string) => {
- setClientId(value);
+ setClientId(value.trim());
}
const updateClientSecret = (value: string) => {
- setClientSecret(value);
+ setClientSecret(value.trim());
}
const validate = () => {
diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx
index 01c14749a027c4..e67e3ba250a441 100644
--- a/components/dashboard/src/start/CreateWorkspace.tsx
+++ b/components/dashboard/src/start/CreateWorkspace.tsx
@@ -258,7 +258,7 @@ function RepositoryNotFoundView(p: { error: StartWorkspaceError }) {
}
// TODO: this should be aware of already granted permissions
- const missingScope = authProvider.host === 'github.com' ? 'repo' : 'read_repository';
+ const missingScope = authProvider.authProviderType === 'GitHub' ? 'repo' : 'read_repository';
const authorizeURL = gitpodHostUrl.withApi({
pathname: '/authorize',
search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${missingScope}`
diff --git a/components/server/ee/src/auth/host-container-mapping.ts b/components/server/ee/src/auth/host-container-mapping.ts
index 90590270d29164..539a8131ddba76 100644
--- a/components/server/ee/src/auth/host-container-mapping.ts
+++ b/components/server/ee/src/auth/host-container-mapping.ts
@@ -8,6 +8,7 @@ import { injectable, interfaces } from "inversify";
import { HostContainerMapping } from "../../../src/auth/host-container-mapping";
import { gitlabContainerModuleEE } from "../gitlab/container-module";
import { bitbucketContainerModuleEE } from "../bitbucket/container-module";
+import { gitHubContainerModuleEE } from "../github/container-module";
@injectable()
export class HostContainerMappingEE extends HostContainerMapping {
@@ -23,6 +24,8 @@ export class HostContainerMappingEE extends HostContainerMapping {
// case "BitbucketServer":
// FIXME
// return (modules || []).concat([bitbucketContainerModuleEE]);
+ case "GitHub":
+ return (modules || []).concat([gitHubContainerModuleEE]);
default:
return modules;
}
diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts
index c232119d063e16..f49fc8f33ae478 100644
--- a/components/server/ee/src/container-module.ts
+++ b/components/server/ee/src/container-module.ts
@@ -23,6 +23,7 @@ import { GithubAppRules } from "./prebuilds/github-app-rules";
import { PrebuildStatusMaintainer } from "./prebuilds/prebuilt-status-maintainer";
import { GitLabApp } from "./prebuilds/gitlab-app";
import { BitbucketApp } from "./prebuilds/bitbucket-app";
+import { GitHubEnterpriseApp } from "./prebuilds/github-enterprise-app";
import { IPrefixContextParser } from "../../src/workspace/context-parser";
import { StartPrebuildContextParser } from "./prebuilds/start-prebuild-context-parser";
import { StartIncrementalPrebuildContextParser } from "./prebuilds/start-incremental-prebuild-context-parser";
@@ -68,6 +69,7 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is
bind(GitLabAppSupport).toSelf().inSingletonScope();
bind(BitbucketApp).toSelf().inSingletonScope();
bind(BitbucketAppSupport).toSelf().inSingletonScope();
+ bind(GitHubEnterpriseApp).toSelf().inSingletonScope();
bind(LicenseEvaluator).toSelf().inSingletonScope();
bind(LicenseKeySource).to(DBLicenseKeySource).inSingletonScope();
diff --git a/components/server/ee/src/github/container-module.ts b/components/server/ee/src/github/container-module.ts
new file mode 100644
index 00000000000000..7650cd19dc6818
--- /dev/null
+++ b/components/server/ee/src/github/container-module.ts
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) 2022 Gitpod GmbH. All rights reserved.
+ * Licensed under the Gitpod Enterprise Source Code License,
+ * See License.enterprise.txt in the project root folder.
+ */
+
+import { ContainerModule } from "inversify";
+import { RepositoryService } from "../../../src/repohost/repo-service";
+import { GitHubService } from "../prebuilds/github-service";
+
+export const gitHubContainerModuleEE = new ContainerModule((_bind, _unbind, _isBound, rebind) => {
+ rebind(RepositoryService).to(GitHubService).inSingletonScope();
+});
\ No newline at end of file
diff --git a/components/server/ee/src/prebuilds/github-enterprise-app.ts b/components/server/ee/src/prebuilds/github-enterprise-app.ts
new file mode 100644
index 00000000000000..684a0772e9666b
--- /dev/null
+++ b/components/server/ee/src/prebuilds/github-enterprise-app.ts
@@ -0,0 +1,197 @@
+/**
+ * Copyright (c) 2022 Gitpod GmbH. All rights reserved.
+ * Licensed under the Gitpod Enterprise Source Code License,
+ * See License.enterprise.txt in the project root folder.
+ */
+
+import * as express from 'express';
+import { createHmac } from 'crypto';
+import { postConstruct, injectable, inject } from 'inversify';
+import { ProjectDB, TeamDB, UserDB } from '@gitpod/gitpod-db/lib';
+import { PrebuildManager } from '../prebuilds/prebuild-manager';
+import { TraceContext } from '@gitpod/gitpod-protocol/lib/util/tracing';
+import { TokenService } from '../../../src/user/token-service';
+import { HostContextProvider } from '../../../src/auth/host-context-provider';
+import { log } from '@gitpod/gitpod-protocol/lib/util/logging';
+import { Project, StartPrebuildResult, User } from '@gitpod/gitpod-protocol';
+import { GitHubService } from './github-service';
+import { URL } from 'url';
+
+@injectable()
+export class GitHubEnterpriseApp {
+
+ @inject(UserDB) protected readonly userDB: UserDB;
+ @inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager;
+ @inject(TokenService) protected readonly tokenService: TokenService;
+ @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider;
+ @inject(ProjectDB) protected readonly projectDB: ProjectDB;
+ @inject(TeamDB) protected readonly teamDB: TeamDB;
+
+ protected _router = express.Router();
+ public static path = '/apps/ghe/';
+
+ @postConstruct()
+ protected init() {
+ this._router.post('/', async (req, res) => {
+ const event = req.header('X-Github-Event');
+ if (event === 'push') {
+ const payload = req.body as GitHubEnterprisePushPayload;
+ const span = TraceContext.startSpan("GitHubEnterpriseApp.handleEvent", {});
+ span.setTag("payload", payload);
+ let user: User | undefined;
+ try {
+ user = await this.findUser({ span }, payload, req);
+ } catch (error) {
+ log.error("Cannot find user.", error, { req })
+ }
+ if (!user) {
+ res.statusCode = 401;
+ res.send();
+ return;
+ }
+ await this.handlePushHook({ span }, payload, user);
+ } else {
+ log.info("Unknown GitHub Enterprise event received", { event });
+ }
+ res.send('OK');
+ });
+ }
+
+ protected async findUser(ctx: TraceContext, payload: GitHubEnterprisePushPayload, req: express.Request): Promise
{
+ const span = TraceContext.startSpan("GitHubEnterpriseApp.findUser", ctx);
+ try {
+ const host = req.header('X-Github-Enterprise-Host');
+ const hostContext = this.hostContextProvider.get(host || '');
+ if (!host || !hostContext) {
+ throw new Error('Unsupported GitHub Enterprise host: ' + host);
+ }
+ const { authProviderId } = hostContext.authProvider;
+ const authId = payload.sender.id;
+ const user = await this.userDB.findUserByIdentity({ authProviderId, authId });
+ if (!user) {
+ throw new Error(`No user found with identity ${authProviderId}/${authId}.`);
+ } else if (!!user.blocked) {
+ throw new Error(`Blocked user ${user.id} tried to start prebuild.`);
+ }
+ const gitpodIdentity = user.identities.find(i => i.authProviderId === TokenService.GITPOD_AUTH_PROVIDER_ID);
+ if (!gitpodIdentity) {
+ throw new Error(`User ${user.id} has no identity for '${TokenService.GITPOD_AUTH_PROVIDER_ID}'.`);
+ }
+ // Verify the webhook signature
+ const signature = req.header('X-Hub-Signature-256');
+ const body = (req as any).rawBody;
+ const tokenEntries = (await this.userDB.findTokensForIdentity(gitpodIdentity)).filter(tokenEntry => {
+ return tokenEntry.token.scopes.includes(GitHubService.PREBUILD_TOKEN_SCOPE);
+ });
+ const signingToken = tokenEntries.find(tokenEntry => {
+ const sig = 'sha256=' + createHmac('sha256', user.id + '|' + tokenEntry.token.value)
+ .update(body)
+ .digest('hex');
+ return sig === signature;
+ });
+ if (!signingToken) {
+ throw new Error(`User ${user.id} has no token matching the payload signature.`);
+ }
+ return user;
+ } finally {
+ span.finish();
+ }
+ }
+
+ protected async handlePushHook(ctx: TraceContext, payload: GitHubEnterprisePushPayload, user: User): Promise {
+ const span = TraceContext.startSpan("GitHubEnterpriseApp.handlePushHook", ctx);
+ try {
+ const contextURL = this.createContextUrl(payload);
+ span.setTag('contextURL', contextURL);
+ const config = await this.prebuildManager.fetchConfig({ span }, user, contextURL);
+ if (!this.prebuildManager.shouldPrebuild(config)) {
+ log.info('GitHub Enterprise push event: No config. No prebuild.');
+ return undefined;
+ }
+
+ log.debug('GitHub Enterprise push event: Starting prebuild.', { contextURL });
+
+ const cloneURL = payload.repository.clone_url;
+ const projectAndOwner = await this.findProjectAndOwner(cloneURL, user);
+
+ const ws = await this.prebuildManager.startPrebuild({ span }, {
+ user: projectAndOwner.user,
+ project: projectAndOwner?.project,
+ branch: this.getBranchFromRef(payload.ref),
+ contextURL,
+ cloneURL,
+ commit: payload.after,
+ });
+ return ws;
+ } finally {
+ span.finish();
+ }
+ }
+
+ /**
+ * Finds the relevant user account and project to the provided webhook event information.
+ *
+ * First of all it tries to find the project for the given `cloneURL`, then it tries to
+ * find the installer, which is also supposed to be a team member. As a fallback, it
+ * looks for a team member which also has a connection with this GitHub Enterprise server.
+ *
+ * @param cloneURL of the webhook event
+ * @param webhookInstaller the user account known from the webhook installation
+ * @returns a promise which resolves to a user account and an optional project.
+ */
+ protected async findProjectAndOwner(cloneURL: string, webhookInstaller: User): Promise<{ user: User, project?: Project }> {
+ const project = await this.projectDB.findProjectByCloneUrl(cloneURL);
+ if (project) {
+ if (project.userId) {
+ const user = await this.userDB.findUserById(project.userId);
+ if (user) {
+ return { user, project };
+ }
+ } else if (project.teamId) {
+ const teamMembers = await this.teamDB.findMembersByTeam(project.teamId || '');
+ if (teamMembers.some(t => t.userId === webhookInstaller.id)) {
+ return { user: webhookInstaller, project };
+ }
+ const hostContext = this.hostContextProvider.get(new URL(cloneURL).host);
+ const authProviderId = hostContext?.authProvider.authProviderId;
+ for (const teamMember of teamMembers) {
+ const user = await this.userDB.findUserById(teamMember.userId);
+ if (user && user.identities.some(i => i.authProviderId === authProviderId)) {
+ return { user, project };
+ }
+ }
+ }
+ }
+ return { user: webhookInstaller };
+ }
+
+ protected getBranchFromRef(ref: string): string | undefined {
+ const headsPrefix = "refs/heads/";
+ if (ref.startsWith(headsPrefix)) {
+ return ref.substring(headsPrefix.length);
+ }
+
+ return undefined;
+ }
+
+ protected createContextUrl(payload: GitHubEnterprisePushPayload) {
+ return `${payload.repository.url}/tree/${this.getBranchFromRef(payload.ref)}`;
+ }
+
+ get router(): express.Router {
+ return this._router;
+ }
+}
+
+interface GitHubEnterprisePushPayload {
+ ref: string;
+ after: string;
+ repository: {
+ url: string;
+ clone_url: string;
+ };
+ sender: {
+ login: string;
+ id: string;
+ };
+}
diff --git a/components/server/ee/src/prebuilds/github-service.ts b/components/server/ee/src/prebuilds/github-service.ts
new file mode 100644
index 00000000000000..259a1598516e3a
--- /dev/null
+++ b/components/server/ee/src/prebuilds/github-service.ts
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) 2022 Gitpod GmbH. All rights reserved.
+ * Licensed under the Gitpod Enterprise Source Code License,
+ * See License.enterprise.txt in the project root folder.
+ */
+
+import { RepositoryService } from "../../../src/repohost/repo-service";
+import { inject, injectable } from "inversify";
+import { GitHubGraphQlEndpoint, GitHubRestApi } from "../../../src/github/api";
+import { GitHubEnterpriseApp } from "./github-enterprise-app";
+import { AuthProviderParams } from "../../../src/auth/auth-provider";
+import { GithubContextParser } from "../../../src/github/github-context-parser";
+import { ProviderRepository, User } from "@gitpod/gitpod-protocol";
+import { Config } from "../../../src/config";
+import { TokenService } from "../../../src/user/token-service";
+
+@injectable()
+export class GitHubService extends RepositoryService {
+
+ static PREBUILD_TOKEN_SCOPE = 'prebuilds';
+
+ @inject(GitHubGraphQlEndpoint) protected readonly githubQueryApi: GitHubGraphQlEndpoint;
+ @inject(GitHubRestApi) protected readonly githubApi: GitHubRestApi;
+ @inject(Config) protected readonly config: Config;
+ @inject(AuthProviderParams) protected authProviderConfig: AuthProviderParams;
+ @inject(TokenService) protected tokenService: TokenService;
+ @inject(GithubContextParser) protected githubContextParser: GithubContextParser;
+
+ async getRepositoriesForAutomatedPrebuilds(user: User): Promise {
+ const repositories = (await this.githubApi.run(user, gh => gh.repos.listForAuthenticatedUser({}))).data;
+ const adminRepositories = repositories.filter(r => !!r.permissions?.admin)
+ return adminRepositories.map(r => {
+ return {
+ name: r.name,
+ cloneUrl: r.clone_url,
+ account: r.owner?.login,
+ accountAvatarUrl: r.owner?.avatar_url,
+ updatedAt: r.updated_at
+ };
+ });
+ }
+
+ async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise {
+ const { host, owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
+ if (host !== this.authProviderConfig.host) {
+ return false;
+ }
+ try {
+ // You need "ADMIN" permission on a repository to be able to install a webhook.
+ // Ref: https://docs.github.com/en/organizations/managing-access-to-your-organizations-repositories/repository-roles-for-an-organization#permissions-for-each-role
+ // Ref: https://docs.github.com/en/graphql/reference/enums#repositorypermission
+ const result: any = await this.githubQueryApi.runQuery(user, `
+ query {
+ repository(name: "${repo}", owner: "${owner}") {
+ viewerPermission
+ }
+ }
+ `);
+ return result.data.repository && result.data.repository.viewerPermission === 'ADMIN';
+ } catch (err) {
+ return false;
+ }
+ }
+
+ async installAutomatedPrebuilds(user: User, cloneUrl: string): Promise {
+ const { owner, repoName: repo } = await this.githubContextParser.parseURL(user, cloneUrl);
+ const webhooks = (await this.githubApi.run(user, gh => gh.repos.listWebhooks({ owner, repo }))).data;
+ for (const webhook of webhooks) {
+ if (webhook.config.url === this.getHookUrl()) {
+ await this.githubApi.run(user, gh => gh.repos.deleteWebhook({ owner, repo, hook_id: webhook.id }));
+ }
+ }
+ const tokenEntry = await this.tokenService.createGitpodToken(user, GitHubService.PREBUILD_TOKEN_SCOPE, cloneUrl);
+ const config = {
+ url: this.getHookUrl(),
+ content_type: 'json',
+ secret: user.id + '|' + tokenEntry.token.value,
+ };
+ await this.githubApi.run(user, gh => gh.repos.createWebhook({ owner, repo, config }));
+ }
+
+ protected getHookUrl() {
+ return this.config.hostUrl.with({
+ pathname: GitHubEnterpriseApp.path
+ }).toString();
+ }
+}
\ No newline at end of file
diff --git a/components/server/ee/src/server.ts b/components/server/ee/src/server.ts
index ee54b423f6eab6..46707cfa330e9b 100644
--- a/components/server/ee/src/server.ts
+++ b/components/server/ee/src/server.ts
@@ -13,12 +13,14 @@ import { GitLabApp } from './prebuilds/gitlab-app';
import { BitbucketApp } from './prebuilds/bitbucket-app';
import { GithubApp } from './prebuilds/github-app';
import { SnapshotService } from './workspace/snapshot-service';
+import { GitHubEnterpriseApp } from './prebuilds/github-enterprise-app';
export class ServerEE extends Server {
@inject(GithubApp) protected readonly githubApp: GithubApp;
@inject(GitLabApp) protected readonly gitLabApp: GitLabApp;
@inject(BitbucketApp) protected readonly bitbucketApp: BitbucketApp;
@inject(SnapshotService) protected readonly snapshotService: SnapshotService;
+ @inject(GitHubEnterpriseApp) protected readonly gitHubEnterpriseApp: GitHubEnterpriseApp;
public async init(app: express.Application) {
await super.init(app);
@@ -43,5 +45,8 @@ export class ServerEE extends Se
log.info("Registered Bitbucket app at " + BitbucketApp.path);
app.use(BitbucketApp.path, this.bitbucketApp.router);
+
+ log.info("Registered GitHub EnterpriseApp app at " + GitHubEnterpriseApp.path);
+ app.use(GitHubEnterpriseApp.path, this.gitHubEnterpriseApp.router);
}
}
\ No newline at end of file
diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts
index defe22809fa153..9ec6519614acf6 100644
--- a/components/server/ee/src/workspace/gitpod-server-impl.ts
+++ b/components/server/ee/src/workspace/gitpod-server-impl.ts
@@ -1476,6 +1476,11 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
if (providerHost === "github.com") {
repositories.push(...(await this.githubAppSupport.getProviderRepositoriesForUser({ user, ...params })));
+ } else if (provider?.authProviderType === "GitHub") {
+ const hostContext = this.hostContextProvider.get(providerHost);
+ if (hostContext?.services) {
+ repositories.push(...(await hostContext.services.repositoryService.getRepositoriesForAutomatedPrebuilds(user)));
+ }
} else if (providerHost === "bitbucket.org" && provider) {
repositories.push(...(await this.bitbucketAppSupport.getProviderRepositoriesForUser({ user, provider })));
} else if (provider?.authProviderType === "GitLab") {
diff --git a/components/server/src/auth/auth-provider-service.ts b/components/server/src/auth/auth-provider-service.ts
index 826b138d9d8000..bf974206616484 100644
--- a/components/server/src/auth/auth-provider-service.ts
+++ b/components/server/src/auth/auth-provider-service.ts
@@ -153,10 +153,6 @@ export class AuthProviderService {
protected callbackUrl = (host: string) => {
const pathname = `/auth/${host}/callback`;
- if (this.config.devBranch) {
- // for example: https://staging.gitpod-dev.com/auth/mydomain.com/gitlab/callback
- return this.config.hostUrl.withoutDomainPrefix(1).with({ pathname }).toString();
- }
return this.config.hostUrl.with({ pathname }).toString();
};
}
diff --git a/components/server/src/github/api.ts b/components/server/src/github/api.ts
index b52c84ce986476..32cdbe838280dc 100644
--- a/components/server/src/github/api.ts
+++ b/components/server/src/github/api.ts
@@ -44,7 +44,7 @@ export class GitHubGraphQlEndpoint {
const { host } = this.config;
const urlString = host === 'github.com' ?
`https://raw.githubusercontent.com/${org}/${name}/${commitish}/${path}` :
- `https://${host}/${org}/${name}/raw/${commitish}/${path}`;
+ `https://${host}/raw/${org}/${name}/${commitish}/${path}`;
const response = await fetch(urlString, {
timeout: 15000,
method: 'GET',
@@ -174,7 +174,7 @@ export class GitHubRestApi {
try {
const response = (await operation(userApi));
const statusCode = response.status;
- if (statusCode !== 200) {
+ if (!(statusCode >= 200 && statusCode < 300)) {
throw new GitHubApiError(response);
}
return response;
diff --git a/components/server/src/projects/projects-service.ts b/components/server/src/projects/projects-service.ts
index b0d7bf93ea7dfe..0bbe85ce241542 100644
--- a/components/server/src/projects/projects-service.ts
+++ b/components/server/src/projects/projects-service.ts
@@ -137,7 +137,7 @@ export class ProjectsService {
const parsedUrl = RepoURL.parseRepoUrl(project.cloneUrl);
const hostContext = parsedUrl?.host ? this.hostContextProvider.get(parsedUrl?.host) : undefined;
const type = hostContext && hostContext.authProvider.info.authProviderType;
- if (type === "GitLab" || type === "Bitbucket") {
+ if (type !== "github.com") {
const repositoryService = hostContext?.services?.repositoryService;
if (repositoryService) {
// Note: For GitLab, we expect .canInstallAutomatedPrebuilds() to always return true, because earlier
diff --git a/components/server/src/repohost/repo-service.ts b/components/server/src/repohost/repo-service.ts
index 3740e919f8209f..21b6f941ac1bf8 100644
--- a/components/server/src/repohost/repo-service.ts
+++ b/components/server/src/repohost/repo-service.ts
@@ -4,12 +4,16 @@
* See License-AGPL.txt in the project root for license information.
*/
-import { User } from "@gitpod/gitpod-protocol";
+import { ProviderRepository, User } from "@gitpod/gitpod-protocol";
import { injectable } from "inversify";
@injectable()
export class RepositoryService {
+ async getRepositoriesForAutomatedPrebuilds(user: User): Promise {
+ return [];
+ }
+
async canInstallAutomatedPrebuilds(user: User, cloneUrl: string): Promise {
return false;
}
diff --git a/components/server/src/server.ts b/components/server/src/server.ts
index 4eac5d0cc1bdc7..2d5f2077a7ef8a 100644
--- a/components/server/src/server.ts
+++ b/components/server/src/server.ts
@@ -117,9 +117,9 @@ export class Server {
});
// Express configuration
- // Read bodies as JSON
- app.use(bodyParser.json())
- app.use(bodyParser.urlencoded({ extended: true }))
+ // Read bodies as JSON (but keep the raw body just in case)
+ app.use(bodyParser.json({ verify: (req, res, buffer) => { (req as any).rawBody = buffer; }}));
+ app.use(bodyParser.urlencoded({ extended: true }));
// Add cookie Parser
app.use(cookieParser());
app.set('trust proxy', 1) // trust first proxy