mirror of
https://github.com/appwrite/console.git
synced 2026-06-06 19:27:48 +00:00
feat: add GitHub App maker to self-hosted Git configuration
Self-hosted instances without VCS configured now see an inline GitHub App maker in Project Settings → Git instead of a static docs link. Uses the GitHub App manifest flow to pre-configure webhooks, permissions, and callback URLs automatically. Includes CSRF protection via sessionStorage, proper hostname validation, and extracts all required env vars (_APP_VCS_GITHUB_APP_NAME, _APP_VCS_GITHUB_PRIVATE_KEY, _APP_VCS_GITHUB_APP_ID, _APP_VCS_GITHUB_CLIENT_ID, _APP_VCS_GITHUB_CLIENT_SECRET, _APP_VCS_GITHUB_WEBHOOK_SECRET) ready to paste into .env. The connectGit component (used mid-flow in function/site creation) is updated to link back to settings instead of external docs, and migrated to Svelte 5 runes.
This commit is contained in:
@@ -5,26 +5,24 @@
|
||||
import { IconGithub } from '@appwrite.io/pink-icons-svelte';
|
||||
import { Alert, Card, Empty, Icon, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { regionalConsoleVariables } from '$routes/(console)/project-[region]-[project]/store';
|
||||
import { page } from '$app/state';
|
||||
|
||||
export let callbackState: Record<string, string> = null;
|
||||
let { callbackState = null }: { callbackState?: Record<string, string> } = $props();
|
||||
|
||||
let isVcsEnabled = $regionalConsoleVariables?._APP_VCS_ENABLED === true;
|
||||
const isVcsEnabled = $derived($regionalConsoleVariables?._APP_VCS_ENABLED === true);
|
||||
|
||||
const settingsUrl = $derived(
|
||||
`/console/project-${page.params.region}-${page.params.project}/settings`
|
||||
);
|
||||
</script>
|
||||
|
||||
<Layout.Stack>
|
||||
{#if !isVcsEnabled && isSelfHosted}
|
||||
<Alert.Inline status="info" title="Installing Git on a self-hosted instance ">
|
||||
<Alert.Inline status="info" title="Set up Git for this instance">
|
||||
<Layout.Stack>
|
||||
<p>
|
||||
Before installing Git in a locally hosted Appwrite project, ensure your
|
||||
environment variables are configured.
|
||||
</p>
|
||||
<p>Create a GitHub App to enable Git deployments on your self-hosted instance.</p>
|
||||
<div>
|
||||
<Button
|
||||
compact
|
||||
external
|
||||
href="https://appwrite.io/docs/advanced/self-hosting/functions#git"
|
||||
>Learn more</Button>
|
||||
<Button compact href={settingsUrl}>Set up GitHub App</Button>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</Alert.Inline>
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { InputText, InputSwitch, Button } from '$lib/elements/forms';
|
||||
import { Icon, Layout } from '@appwrite.io/pink-svelte';
|
||||
import { IconDuplicate, IconRefresh } from '@appwrite.io/pink-icons-svelte';
|
||||
import { addNotification } from '$lib/stores/notifications';
|
||||
import { copy } from '$lib/helpers/copy';
|
||||
|
||||
const STATE_KEY = 'appwrite-github-app-manifest-state';
|
||||
|
||||
type GitHubAppCredentials = {
|
||||
slug: string;
|
||||
name: string;
|
||||
id: number;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
webhook_secret: string;
|
||||
pem: string;
|
||||
};
|
||||
|
||||
let hostname = $state('');
|
||||
let isOrg = $state(false);
|
||||
let orgName = $state('');
|
||||
let credentials = $state<GitHubAppCredentials | null>(null);
|
||||
let exchanging = $state(false);
|
||||
let copied = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
function isValidHostname(value: string): boolean {
|
||||
if (!value || value.startsWith('.') || value.endsWith('.')) return false;
|
||||
const parts = value.split('.');
|
||||
if (parts.length < 2) return false;
|
||||
for (const part of parts) {
|
||||
if (!part || part.startsWith('-') || part.endsWith('-')) return false;
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(part)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function generateAppName(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let suffix = '';
|
||||
for (let i = 0; i < 6; i++) suffix += chars[Math.floor(Math.random() * chars.length)];
|
||||
return `appwrite-self-hosted-${suffix}`;
|
||||
}
|
||||
|
||||
function submit() {
|
||||
submitError = null;
|
||||
const state = generateState();
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
|
||||
const host = hostname.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
const redirectUrl = new URL(page.url.origin + page.url.pathname);
|
||||
redirectUrl.searchParams.set('github_app_setup', 'callback');
|
||||
|
||||
const manifest = JSON.stringify({
|
||||
name: generateAppName(),
|
||||
url: `https://${host}`,
|
||||
hook_attributes: { url: `https://${host}/v1/vcs/github/events` },
|
||||
redirect_url: redirectUrl.toString(),
|
||||
callback_urls: [
|
||||
`https://${host}/v1/vcs/github/callback`,
|
||||
`https://${host}/v1/account/sessions/oauth2/callback/github/console`
|
||||
],
|
||||
request_oauth_on_install: true,
|
||||
setup_on_update: true,
|
||||
public: true,
|
||||
default_permissions: {
|
||||
administration: 'write',
|
||||
checks: 'write',
|
||||
statuses: 'write',
|
||||
contents: 'write',
|
||||
issues: 'write',
|
||||
metadata: 'read',
|
||||
pull_requests: 'write',
|
||||
repository_hooks: 'write',
|
||||
emails: 'read'
|
||||
},
|
||||
default_events: ['pull_request', 'push']
|
||||
});
|
||||
|
||||
const baseUrl =
|
||||
isOrg && orgName.trim()
|
||||
? `https://github.com/organizations/${orgName.trim()}/settings/apps/new`
|
||||
: 'https://github.com/settings/apps/new';
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `${baseUrl}?state=${state}`;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'manifest';
|
||||
input.value = manifest;
|
||||
form.appendChild(input);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const code = page.url.searchParams.get('code');
|
||||
const state = page.url.searchParams.get('state');
|
||||
const setup = page.url.searchParams.get('github_app_setup');
|
||||
|
||||
if (setup === 'callback' && code && state && !credentials && !exchanging) {
|
||||
await exchangeCode(code, state);
|
||||
const cleanUrl = new URL(page.url);
|
||||
['code', 'state', 'github_app_setup'].forEach((p) => cleanUrl.searchParams.delete(p));
|
||||
await goto(cleanUrl.toString(), { replaceState: true, noScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function exchangeCode(code: string, state: string) {
|
||||
exchanging = true;
|
||||
submitError = null;
|
||||
|
||||
const storedState = sessionStorage.getItem(STATE_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
|
||||
if (!storedState || storedState !== state) {
|
||||
submitError = 'State mismatch. This may be a CSRF attack. Please try again.';
|
||||
exchanging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/app-manifests/${code}/conversions`,
|
||||
{ method: 'POST', headers: { Accept: 'application/vnd.github+json' } }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
throw new Error(body?.message ?? `GitHub API returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
credentials = data as GitHubAppCredentials;
|
||||
} catch (e) {
|
||||
submitError = (e as Error).message;
|
||||
addNotification({ type: 'error', message: (e as Error).message });
|
||||
} finally {
|
||||
exchanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
const envVars = $derived.by(() => {
|
||||
if (!credentials) return '';
|
||||
const pem = credentials.pem.replace(/\n$/, '');
|
||||
const name = credentials.slug ?? credentials.name;
|
||||
return [
|
||||
`_APP_VCS_GITHUB_APP_NAME="${name}"`,
|
||||
`_APP_VCS_GITHUB_PRIVATE_KEY="${pem}"`,
|
||||
`_APP_VCS_GITHUB_APP_ID="${credentials.id}"`,
|
||||
`_APP_VCS_GITHUB_CLIENT_ID="${credentials.client_id}"`,
|
||||
`_APP_VCS_GITHUB_CLIENT_SECRET="${credentials.client_secret}"`,
|
||||
`_APP_VCS_GITHUB_WEBHOOK_SECRET="${credentials.webhook_secret}"`
|
||||
].join('\n');
|
||||
});
|
||||
|
||||
async function copyEnvVars() {
|
||||
const success = await copy(envVars);
|
||||
if (success) {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} else {
|
||||
addNotification({ type: 'error', message: 'Unable to copy to clipboard' });
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
credentials = null;
|
||||
submitError = null;
|
||||
hostname = '';
|
||||
isOrg = false;
|
||||
orgName = '';
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
}
|
||||
|
||||
const isValid = $derived(isValidHostname(hostname) && (!isOrg || orgName.trim().length > 0));
|
||||
</script>
|
||||
|
||||
{#if exchanging}
|
||||
<Layout.Stack alignItems="center" gap="m">
|
||||
<span class="loader" aria-label="Creating GitHub App..."></span>
|
||||
<p class="text">Creating your GitHub App...</p>
|
||||
</Layout.Stack>
|
||||
{:else if credentials}
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Stack gap="xs">
|
||||
<p class="body-text-1 u-bold">GitHub App created</p>
|
||||
<p class="text">
|
||||
Set these environment variables in the <code>.env</code> file of your Appwrite instance,
|
||||
then restart your instance.
|
||||
</p>
|
||||
</Layout.Stack>
|
||||
|
||||
<div class="env-block">
|
||||
<div class="env-block-header">
|
||||
<span class="tag">.env</span>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-text is-only-icon"
|
||||
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
||||
onclick={copyEnvVars}>
|
||||
<Icon icon={IconDuplicate} size="s" />
|
||||
</button>
|
||||
</div>
|
||||
<pre class="env-block-body"><code>{envVars}</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button secondary on:click={reset}>
|
||||
<Icon slot="start" icon={IconRefresh} size="s" />
|
||||
Create another GitHub App
|
||||
</Button>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
{:else}
|
||||
<Layout.Stack gap="l">
|
||||
<Layout.Stack gap="xs">
|
||||
<p class="body-text-1 u-bold">Create GitHub App</p>
|
||||
<p class="text">
|
||||
Registers a GitHub App with the correct webhooks, permissions, and callback URLs
|
||||
pre-configured using the <a
|
||||
class="u-underline"
|
||||
href="https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest"
|
||||
target="_blank"
|
||||
rel="noreferrer">GitHub App manifest flow</a
|
||||
>. For manual configuration, use the
|
||||
<a
|
||||
class="u-underline"
|
||||
href="https://github.com/settings/apps/new"
|
||||
target="_blank"
|
||||
rel="noreferrer">GitHub app creation form</a> directly.
|
||||
</p>
|
||||
</Layout.Stack>
|
||||
|
||||
{#if submitError}
|
||||
<p class="text u-color-text-danger">{submitError}</p>
|
||||
{/if}
|
||||
|
||||
<Layout.Stack gap="l">
|
||||
<InputText
|
||||
id="github-hostname"
|
||||
label="Hostname"
|
||||
placeholder="appwrite.example.com"
|
||||
helper="Domain of your Appwrite instance, without https://"
|
||||
bind:value={hostname}
|
||||
required />
|
||||
<InputSwitch id="github-is-org" label="Create on organization?" bind:value={isOrg} />
|
||||
{#if isOrg}
|
||||
<InputText
|
||||
id="github-org-name"
|
||||
label="Organization handle"
|
||||
placeholder="my-org"
|
||||
bind:value={orgName}
|
||||
required />
|
||||
{/if}
|
||||
<div>
|
||||
<Button on:click={submit} disabled={!isValid}>Create GitHub App</Button>
|
||||
</div>
|
||||
</Layout.Stack>
|
||||
</Layout.Stack>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.env-block {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--border-radius-medium);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
|
||||
.env-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
background-color: hsl(var(--color-surface-2));
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: var(--font-size-0);
|
||||
color: hsl(var(--color-text-2));
|
||||
}
|
||||
|
||||
.env-block-body {
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
background-color: hsl(var(--color-surface-1));
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,3 +9,4 @@ export { default as ConnectBehaviour } from './connectBehaviour.svelte';
|
||||
export { default as ProductionBranchFieldset } from './productionBranchFieldset.svelte';
|
||||
export { default as RepositoryCard } from './repositoryCard.svelte';
|
||||
export { default as ConnectRepoModal } from './connectRepoModal.svelte';
|
||||
export { default as GitHubAppMaker } from './gitHubAppMaker.svelte';
|
||||
|
||||
+2
-11
@@ -9,7 +9,6 @@
|
||||
import { isSelfHosted } from '$lib/system';
|
||||
import {
|
||||
ActionMenu,
|
||||
Alert,
|
||||
Card,
|
||||
Empty,
|
||||
Icon,
|
||||
@@ -25,6 +24,7 @@
|
||||
IconPlus,
|
||||
IconXCircle
|
||||
} from '@appwrite.io/pink-icons-svelte';
|
||||
import { GitHubAppMaker } from '$lib/components/git';
|
||||
import DualTimeView from '$lib/components/dualTimeView.svelte';
|
||||
import { Click, trackEvent } from '$lib/actions/analytics';
|
||||
import type { ComponentType } from 'svelte';
|
||||
@@ -172,16 +172,7 @@
|
||||
{/if}
|
||||
</Layout.Stack>
|
||||
{:else if isSelfHosted && !isVcsEnabled}
|
||||
<Alert.Inline status="info" title="Installing Git on a self-hosted instance">
|
||||
Before installing Git in a locally hosted Appwrite project, ensure your environment
|
||||
variables are configured.
|
||||
<svelte:fragment slot="actions">
|
||||
<FormButton
|
||||
compact
|
||||
href="https://appwrite.io/docs/advanced/self-hosting/configuration/version-control"
|
||||
external>Learn more</FormButton>
|
||||
</svelte:fragment>
|
||||
</Alert.Inline>
|
||||
<GitHubAppMaker />
|
||||
{:else}
|
||||
<Card.Base padding="none" border="dashed">
|
||||
<Empty
|
||||
|
||||
Reference in New Issue
Block a user