This commit is contained in:
@@ -5,6 +5,11 @@ import { env } from '$env/dynamic/private';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { db } from '$lib/server/db';
|
||||
|
||||
if (!env.ORIGIN) throw new Error('ORIGIN is not set');
|
||||
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
|
||||
if (!env.GITHUB_CLIENT_ID) throw new Error('GITHUB_CLIENT_ID is not set');
|
||||
if (!env.GITHUB_CLIENT_SECRET) throw new Error('GITHUB_CLIENT_SECRET is not set');
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: env.ORIGIN,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
|
||||
@@ -1,9 +1,140 @@
|
||||
import { pgTable, serial, integer, text } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { user } from './auth.schema';
|
||||
|
||||
export const projectStatus = ['planning', 'active', 'paused', 'done'] as const;
|
||||
export const taskStatus = ['todo', 'doing', 'blocked', 'done'] as const;
|
||||
export const issueSource = ['github', 'gitea'] as const;
|
||||
|
||||
export const githubInstallation = pgTable(
|
||||
'github_installation',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
installationId: text('installation_id').notNull().unique(),
|
||||
accountLogin: text('account_login').notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('github_installation_installation_id_idx').on(table.installationId)]
|
||||
);
|
||||
|
||||
export const githubRepositoryLink = pgTable(
|
||||
'github_repository_link',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
projectId: integer('project_id')
|
||||
.notNull()
|
||||
.references(() => project.id, { onDelete: 'cascade' }),
|
||||
installationId: integer('installation_id')
|
||||
.notNull()
|
||||
.references(() => githubInstallation.id, { onDelete: 'cascade' }),
|
||||
owner: text('owner').notNull(),
|
||||
repo: text('repo').notNull(),
|
||||
fullName: text('full_name').notNull().unique(),
|
||||
defaultBranch: text('default_branch').notNull().default('main'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('github_repository_link_project_id_idx').on(table.projectId)]
|
||||
);
|
||||
|
||||
export const task = pgTable('task', {
|
||||
id: serial('id').primaryKey(),
|
||||
projectId: integer('project_id')
|
||||
.notNull()
|
||||
.references(() => project.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
priority: integer('priority').notNull().default(1)
|
||||
description: text('description').notNull().default(''),
|
||||
status: text('status', { enum: taskStatus }).notNull().default('todo'),
|
||||
priority: integer('priority').notNull().default(1),
|
||||
dueDate: timestamp('due_date'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const project = pgTable('project', {
|
||||
id: serial('id').primaryKey(),
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description').notNull().default(''),
|
||||
status: text('status', { enum: projectStatus }).notNull().default('planning'),
|
||||
progress: integer('progress').notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const issue = pgTable('issue', {
|
||||
id: serial('id').primaryKey(),
|
||||
projectId: integer('project_id')
|
||||
.notNull()
|
||||
.references(() => project.id, { onDelete: 'cascade' }),
|
||||
provider: text('provider', { enum: issueSource }).notNull(),
|
||||
externalId: text('external_id').notNull(),
|
||||
url: text('url').notNull(),
|
||||
title: text('title').notNull(),
|
||||
state: text('state').notNull(),
|
||||
labels: text('labels').notNull().default('[]'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at')
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const githubInstallationRelations = relations(githubInstallation, ({ one, many }) => ({
|
||||
user: one(user, {
|
||||
fields: [githubInstallation.userId],
|
||||
references: [user.id]
|
||||
}),
|
||||
repositories: many(githubRepositoryLink)
|
||||
}));
|
||||
|
||||
export const githubRepositoryLinkRelations = relations(githubRepositoryLink, ({ one }) => ({
|
||||
project: one(project, {
|
||||
fields: [githubRepositoryLink.projectId],
|
||||
references: [project.id]
|
||||
}),
|
||||
installation: one(githubInstallation, {
|
||||
fields: [githubRepositoryLink.installationId],
|
||||
references: [githubInstallation.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const projectRelations = relations(project, ({ many }) => ({
|
||||
tasks: many(task),
|
||||
issues: many(issue),
|
||||
githubRepositoryLinks: many(githubRepositoryLink)
|
||||
}));
|
||||
|
||||
export const taskRelations = relations(task, ({ one }) => ({
|
||||
project: one(project, {
|
||||
fields: [task.projectId],
|
||||
references: [project.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const issueRelations = relations(issue, ({ one }) => ({
|
||||
project: one(project, {
|
||||
fields: [issue.projectId],
|
||||
references: [project.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export * from './auth.schema';
|
||||
|
||||
16
src/lib/server/events.ts
Normal file
16
src/lib/server/events.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const listeners = new Set<ReadableStreamDefaultController<string>>();
|
||||
|
||||
export function subscribe(controller: ReadableStreamDefaultController<string>) {
|
||||
listeners.add(controller);
|
||||
return () => listeners.delete(controller);
|
||||
}
|
||||
|
||||
export function publish(message: string) {
|
||||
for (const controller of listeners) {
|
||||
try {
|
||||
controller.enqueue(`data: ${message}\n\n`);
|
||||
} catch {
|
||||
listeners.delete(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/lib/server/github-app.ts
Normal file
176
src/lib/server/github-app.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createHmac, createPrivateKey, createSign } from 'node:crypto';
|
||||
|
||||
export type GitHubRepository = {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
default_branch: string;
|
||||
owner: { login: string };
|
||||
};
|
||||
|
||||
type InstallState = {
|
||||
redirect: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
function requireEnv(name: string) {
|
||||
switch (name) {
|
||||
case 'GITHUB_APP_ID':
|
||||
if (!env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set');
|
||||
return env.GITHUB_APP_ID;
|
||||
case 'GITHUB_APP_SLUG':
|
||||
if (!env.GITHUB_APP_SLUG) throw new Error('GITHUB_APP_SLUG is not set');
|
||||
return env.GITHUB_APP_SLUG;
|
||||
case 'GITHUB_APP_PRIVATE_KEY':
|
||||
if (!env.GITHUB_APP_PRIVATE_KEY) throw new Error('GITHUB_APP_PRIVATE_KEY is not set');
|
||||
return env.GITHUB_APP_PRIVATE_KEY;
|
||||
case 'BETTER_AUTH_SECRET':
|
||||
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
|
||||
return env.BETTER_AUTH_SECRET;
|
||||
default:
|
||||
throw new Error(`${name} is not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasGitHubAppPrivateKey() {
|
||||
return Boolean(env.GITHUB_APP_PRIVATE_KEY);
|
||||
}
|
||||
|
||||
function normalizePrivateKey(key: string) {
|
||||
return key.includes('-----BEGIN') ? key.replace(/\\n/g, '\n') : Buffer.from(key, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
function base64urlJson(value: unknown) {
|
||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
}
|
||||
|
||||
function signState(payload: InstallState, secret: string) {
|
||||
const serialized = base64urlJson(payload);
|
||||
const signature = createHmac('sha256', secret).update(serialized).digest('base64url');
|
||||
return `${serialized}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyState(state: string, secret: string) {
|
||||
const [payload, signature] = state.split('.');
|
||||
if (!payload || !signature) return null;
|
||||
const expected = createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
if (expected !== signature) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as InstallState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitHubInstallUrl(input: { redirect?: string; userId: string }) {
|
||||
const slug = requireEnv('GITHUB_APP_SLUG');
|
||||
const secret = requireEnv('BETTER_AUTH_SECRET');
|
||||
const redirect = input.redirect ?? '/integrations';
|
||||
const base = new URL(`https://github.com/apps/${slug}/installations/new`);
|
||||
base.searchParams.set('state', signState({ redirect, userId: input.userId }, secret));
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
export function decodeInstallState(state?: string) {
|
||||
if (!state) return { redirect: '/integrations' };
|
||||
const secret = requireEnv('BETTER_AUTH_SECRET');
|
||||
const parsed = verifyState(state, secret);
|
||||
if (!parsed) return { redirect: '/integrations' };
|
||||
return { redirect: parsed.redirect || '/integrations', userId: parsed.userId };
|
||||
}
|
||||
|
||||
function createGitHubAppJwt() {
|
||||
const appId = requireEnv('GITHUB_APP_ID');
|
||||
const privateKey = requireEnv('GITHUB_APP_PRIVATE_KEY');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64urlJson({ alg: 'RS256', typ: 'JWT' });
|
||||
const payload = base64urlJson({ iat: now - 60, exp: now + 9 * 60, iss: appId });
|
||||
const signer = createSign('RSA-SHA256');
|
||||
signer.update(`${header}.${payload}`);
|
||||
signer.end();
|
||||
const key = createPrivateKey(normalizePrivateKey(privateKey));
|
||||
const signature = signer.sign(key, 'base64url');
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
async function githubAppRequest(path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'x-github-api-version': '2022-11-28',
|
||||
...(init.headers ?? {})
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub App request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getGitHubInstallation(installationId: string) {
|
||||
const response = await githubAppRequest(`/app/installations/${installationId}`, {
|
||||
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
|
||||
});
|
||||
return (await response.json()) as {
|
||||
id: number;
|
||||
account?: { login?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGitHubInstallationAccessToken(installationId: string) {
|
||||
const response = await githubAppRequest(`/app/installations/${installationId}/access_tokens`, {
|
||||
method: 'POST',
|
||||
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
|
||||
});
|
||||
return (await response.json()) as { token: string; expires_at: string };
|
||||
}
|
||||
|
||||
export async function listGitHubInstallationRepositories(installationId: string) {
|
||||
const { token } = await getGitHubInstallationAccessToken(installationId);
|
||||
const response = await fetch('https://api.github.com/installation/repositories?per_page=100', {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'x-github-api-version': '2022-11-28',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub repository list failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { repositories: GitHubRepository[] };
|
||||
return data.repositories;
|
||||
}
|
||||
|
||||
export async function listGitHubRepositoryIssues(input: { installationId: string; owner: string; repo: string }) {
|
||||
const { token } = await getGitHubInstallationAccessToken(input.installationId);
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/issues?state=all&per_page=100`,
|
||||
{
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'x-github-api-version': '2022-11-28',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub issue list failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as Array<{
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
state: 'open' | 'closed';
|
||||
html_url: string;
|
||||
labels: Array<{ name: string }>;
|
||||
pull_request?: unknown;
|
||||
}>;
|
||||
}
|
||||
Reference in New Issue
Block a user