diff --git a/ee/tabby-db/migrations/0022_github-provider.down.sql b/ee/tabby-db/migrations/0022_github-provider.down.sql new file mode 100644 index 000000000000..2af049ced3da --- /dev/null +++ b/ee/tabby-db/migrations/0022_github-provider.down.sql @@ -0,0 +1 @@ +DROP TABLE github_repository_provider; diff --git a/ee/tabby-db/migrations/0022_github-provider.up.sql b/ee/tabby-db/migrations/0022_github-provider.up.sql new file mode 100644 index 000000000000..0634b42cb7ac --- /dev/null +++ b/ee/tabby-db/migrations/0022_github-provider.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE github_repository_provider( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + application_id TEXT NOT NULL, + secret TEXT NOT NULL, + access_token TEXT, + CONSTRAINT `idx_application_id` UNIQUE (`application_id`) +); diff --git a/ee/tabby-db/schema.sqlite b/ee/tabby-db/schema.sqlite index b11dc9144c9d..e7846cb398f0 100644 Binary files a/ee/tabby-db/schema.sqlite and b/ee/tabby-db/schema.sqlite differ diff --git a/ee/tabby-db/src/github_repository_provider.rs b/ee/tabby-db/src/github_repository_provider.rs new file mode 100644 index 000000000000..b2741d238650 --- /dev/null +++ b/ee/tabby-db/src/github_repository_provider.rs @@ -0,0 +1,98 @@ +use anyhow::{anyhow, Result}; +use sqlx::{prelude::FromRow, query, query_as}; +use tabby_db_macros::query_paged_as; + +use crate::{DbConn, SQLXResultExt}; + +#[derive(FromRow)] +pub struct GithubRepositoryProviderDAO { + pub id: i64, + pub display_name: String, + pub application_id: String, + pub secret: String, + pub access_token: Option, +} + +impl DbConn { + pub async fn create_github_provider( + &self, + name: String, + application_id: String, + secret: String, + ) -> Result { + let res = query!("INSERT INTO github_repository_provider (display_name, application_id, secret) VALUES ($1, $2, $3);", + name, + application_id, + secret + ).execute(&self.pool).await.unique_error("GitHub Application ID already exists")?; + Ok(res.last_insert_rowid()) + } + + pub async fn get_github_provider(&self, id: i64) -> Result { + let provider = query_as!( + GithubRepositoryProviderDAO, + "SELECT id, display_name, application_id, secret, access_token FROM github_repository_provider WHERE id = ?;", + id + ) + .fetch_one(&self.pool) + .await?; + Ok(provider) + } + + pub async fn delete_github_provider(&self, id: i64) -> Result<()> { + let res = query!("DELETE FROM github_repository_provider WHERE id = ?;", id) + .execute(&self.pool) + .await?; + if res.rows_affected() != 1 { + return Err(anyhow!("No github provider details to delete")); + } + Ok(()) + } + + pub async fn update_github_provider_access_token( + &self, + id: i64, + access_token: String, + ) -> Result<()> { + let res = query!( + "UPDATE github_repository_provider SET access_token = ? WHERE id = ?", + access_token, + id + ) + .execute(&self.pool) + .await?; + + if res.rows_affected() != 1 { + return Err(anyhow!( + "The specified Github repository provider does not exist" + )); + } + + Ok(()) + } + + pub async fn list_github_repository_providers( + &self, + limit: Option, + skip_id: Option, + backwards: bool, + ) -> Result> { + let providers = query_paged_as!( + GithubRepositoryProviderDAO, + "github_repository_provider", + [ + "id", + "display_name", + "application_id", + "secret", + "access_token" + ], + limit, + skip_id, + backwards + ) + .fetch_all(&self.pool) + .await?; + Ok(providers) + } +} diff --git a/ee/tabby-db/src/lib.rs b/ee/tabby-db/src/lib.rs index 2fc01f669e48..5c4c4422b47c 100644 --- a/ee/tabby-db/src/lib.rs +++ b/ee/tabby-db/src/lib.rs @@ -4,6 +4,7 @@ use anyhow::anyhow; use cache::Cache; use chrono::{DateTime, NaiveDateTime, Utc}; pub use email_setting::EmailSettingDAO; +pub use github_repository_provider::GithubRepositoryProviderDAO; pub use invitations::InvitationDAO; pub use job_runs::JobRunDAO; pub use oauth_credential::OAuthCredentialDAO; @@ -17,6 +18,7 @@ pub use users::UserDAO; pub mod cache; mod email_setting; +mod github_repository_provider; mod invitations; mod job_runs; mod oauth_credential; diff --git a/ee/tabby-webserver/graphql/schema.graphql b/ee/tabby-webserver/graphql/schema.graphql index 5aa4cf4d2bd8..23ef1a79eb1e 100644 --- a/ee/tabby-webserver/graphql/schema.graphql +++ b/ee/tabby-webserver/graphql/schema.graphql @@ -3,6 +3,11 @@ enum Language { PYTHON } +type GithubRepositoryProviderEdge { + node: GithubRepositoryProvider! + cursor: String! +} + type JobRun { id: ID! job: String! @@ -44,6 +49,11 @@ type LicenseInfo { expiresAt: DateTimeUtc } +type GithubRepositoryProviderConnection { + edges: [GithubRepositoryProviderEdge!]! + pageInfo: PageInfo! +} + input SecuritySettingInput { allowedRegisterDomainList: [String!]! disableClientSideTelemetry: Boolean! @@ -72,6 +82,12 @@ type ServerInfo { allowSelfSignup: Boolean! } +type GithubRepositoryProvider { + id: ID! + displayName: String! + applicationId: String! +} + input PasswordChangeInput { oldPassword: String newPassword1: String! @@ -176,6 +192,7 @@ type Query { me: User! users(after: String, before: String, first: Int, last: Int): UserConnection! invitations(after: String, before: String, first: Int, last: Int): InvitationConnection! + githubRepositoryProviders(after: String, before: String, first: Int, last: Int): GithubRepositoryProviderConnection! jobRuns(ids: [ID!], jobs: [String!], after: String, before: String, first: Int, last: Int): JobRunConnection! jobRunStats(jobs: [String!]): JobStats! emailSetting: EmailSetting diff --git a/ee/tabby-webserver/src/handler.rs b/ee/tabby-webserver/src/handler.rs index 31dbe01d9ded..8ac832f6edb2 100644 --- a/ee/tabby-webserver/src/handler.rs +++ b/ee/tabby-webserver/src/handler.rs @@ -19,7 +19,7 @@ use tabby_db::DbConn; use tracing::{error, warn}; use crate::{ - cron, hub, oauth, + cron, hub, integrations, oauth, repositories::{self, RepositoryCache}, schema::{auth::AuthenticationService, create_schema, Schema, ServiceLocator}, service::{create_service_locator, event_logger::create_event_logger}, @@ -84,6 +84,10 @@ impl WebserverHandle { "/repositories", repositories::routes(rs.clone(), ctx.auth()), ) + .nest( + "/integrations/github", + integrations::github::routes(ctx.setting(), ctx.github_repository_provider()), + ) .route( "/avatar/:id", routing::get(avatar) diff --git a/ee/tabby-webserver/src/integrations.rs b/ee/tabby-webserver/src/integrations.rs new file mode 100644 index 000000000000..72246d32d261 --- /dev/null +++ b/ee/tabby-webserver/src/integrations.rs @@ -0,0 +1 @@ +pub mod github; diff --git a/ee/tabby-webserver/src/integrations/github.rs b/ee/tabby-webserver/src/integrations/github.rs new file mode 100644 index 000000000000..a0dd22e42d70 --- /dev/null +++ b/ee/tabby-webserver/src/integrations/github.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +use anyhow::Result; +use axum::{ + extract::{Path, Query, State}, + response::Redirect, + routing, Router, +}; +use hyper::StatusCode; +use juniper::ID; +use serde::Deserialize; +use tracing::error; +use url::Url; + +use crate::{ + oauth::github::GithubOAuthResponse, + schema::{ + github_repository_provider::GithubRepositoryProviderService, setting::SettingService, + }, +}; + +#[derive(Deserialize)] +struct CallbackParams { + state: ID, + code: String, +} + +#[derive(Clone)] +struct IntegrationState { + pub settings: Arc, + pub github_repository_provider: Arc, +} + +pub fn routes( + settings: Arc, + github_repository_provider: Arc, +) -> Router { + let state = IntegrationState { + settings, + github_repository_provider, + }; + Router::new() + .route("/connect/:id", routing::get(connect)) + .route("/callback", routing::get(callback)) + .with_state(state) +} + +fn get_authorize_url(client_id: &str, redirect_uri: &str, id: &ID) -> Result { + Ok(Url::parse_with_params( + "https://github.com/login/oauth/authorize", + &[ + ("client_id", client_id), + ("response_type", "code"), + ("scope", "repo"), + ( + "redirect_uri", + &format!("{redirect_uri}/integrations/github/callback"), + ), + ("state", &id.to_string()), + ], + )?) +} + +async fn exchange_access_token( + state: &IntegrationState, + params: &CallbackParams, +) -> Result { + let client = reqwest::Client::new(); + + let client_id = state + .github_repository_provider + .get_github_repository_provider(params.state.clone()) + .await? + .application_id; + + let secret = state + .github_repository_provider + .read_github_repository_provider_secret(params.state.clone()) + .await?; + + Ok(client + .post("https://github.com/login/oauth/access_token") + .header(reqwest::header::ACCEPT, "application/json") + .form(&[ + ("client_id", &client_id), + ("client_secret", &secret), + ("code", ¶ms.code), + ]) + .send() + .await? + .json() + .await?) +} + +async fn callback( + State(state): State, + Query(params): Query, +) -> Result { + let response = match exchange_access_token(&state, ¶ms).await { + Ok(response) => response, + Err(e) => { + error!("Failed to exchange access token: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + if let Err(e) = state + .github_repository_provider + .update_github_repository_provider_access_token(params.state, response.access_token) + .await + { + error!("Failed to update github access token: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + Ok(Redirect::temporary("/")) +} + +async fn connect( + State(state): State, + Path(id): Path, +) -> Result { + let network_setting = match state.settings.read_network_setting().await { + Ok(setting) => setting, + Err(e) => { + error!("Failed to read network setting: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + let external_url = network_setting.external_url; + let provider = match state + .github_repository_provider + .get_github_repository_provider(id.clone()) + .await + { + Ok(provider) => provider, + Err(e) => { + error!("Github repository provider not found: {e}"); + return Err(StatusCode::NOT_FOUND); + } + }; + + let redirect = match get_authorize_url(&provider.application_id, &external_url, &id) { + Ok(redirect) => redirect, + Err(e) => { + error!("Failed to generate callback URL: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + Ok(Redirect::temporary(redirect.as_str())) +} diff --git a/ee/tabby-webserver/src/lib.rs b/ee/tabby-webserver/src/lib.rs index d20b7d053cdc..f2c0ee9f51dc 100644 --- a/ee/tabby-webserver/src/lib.rs +++ b/ee/tabby-webserver/src/lib.rs @@ -3,6 +3,7 @@ mod cron; mod handler; mod hub; +mod integrations; mod oauth; mod repositories; mod schema; diff --git a/ee/tabby-webserver/src/oauth/github.rs b/ee/tabby-webserver/src/oauth/github.rs index c7faefe57d8c..32d9c7e598ed 100644 --- a/ee/tabby-webserver/src/oauth/github.rs +++ b/ee/tabby-webserver/src/oauth/github.rs @@ -9,20 +9,20 @@ use crate::schema::auth::{AuthenticationService, OAuthCredential, OAuthProvider} #[derive(Debug, Deserialize)] #[allow(dead_code)] -struct GithubOAuthResponse { +pub struct GithubOAuthResponse { #[serde(default)] - access_token: String, + pub access_token: String, #[serde(default)] - scope: String, + pub scope: String, #[serde(default)] - token_type: String, + pub token_type: String, #[serde(default)] - error: String, + pub error: String, #[serde(default)] - error_description: String, + pub error_description: String, #[serde(default)] - error_uri: String, + pub error_uri: String, } #[derive(Debug, Deserialize)] diff --git a/ee/tabby-webserver/src/schema/github_repository_provider.rs b/ee/tabby-webserver/src/schema/github_repository_provider.rs new file mode 100644 index 000000000000..334c62e23d5c --- /dev/null +++ b/ee/tabby-webserver/src/schema/github_repository_provider.rs @@ -0,0 +1,49 @@ +use async_trait::async_trait; +use juniper::{GraphQLObject, ID}; +use juniper_axum::relay::NodeType; + +use super::Context; +use crate::schema::Result; + +#[derive(GraphQLObject, Debug)] +#[graphql(context = Context)] +pub struct GithubRepositoryProvider { + pub id: ID, + pub display_name: String, + pub application_id: String, +} + +impl NodeType for GithubRepositoryProvider { + type Cursor = String; + + fn cursor(&self) -> Self::Cursor { + self.id.to_string() + } + + fn connection_type_name() -> &'static str { + "GithubRepositoryProviderConnection" + } + + fn edge_type_name() -> &'static str { + "GithubRepositoryProviderEdge" + } +} + +#[async_trait] +pub trait GithubRepositoryProviderService: Send + Sync { + async fn get_github_repository_provider(&self, id: ID) -> Result; + async fn read_github_repository_provider_secret(&self, id: ID) -> Result; + async fn update_github_repository_provider_access_token( + &self, + id: ID, + access_token: String, + ) -> Result<()>; + + async fn list_github_repository_providers( + &self, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result>; +} diff --git a/ee/tabby-webserver/src/schema/mod.rs b/ee/tabby-webserver/src/schema/mod.rs index 3168b09bd8a4..a6f658f39398 100644 --- a/ee/tabby-webserver/src/schema/mod.rs +++ b/ee/tabby-webserver/src/schema/mod.rs @@ -1,6 +1,7 @@ pub mod analytic; pub mod auth; pub mod email; +pub mod github_repository_provider; pub mod job; pub mod license; pub mod repository; @@ -36,13 +37,15 @@ use self::{ RequestInvitationInput, RequestPasswordResetEmailInput, UpdateOAuthCredentialInput, }, email::{EmailService, EmailSetting, EmailSettingInput}, + github_repository_provider::{GithubRepositoryProvider, GithubRepositoryProviderService}, job::JobStats, license::{IsLicenseValid, LicenseInfo, LicenseService, LicenseType}, - repository::{FileEntrySearchResult, Repository, RepositoryService}, + repository::{Repository, RepositoryService}, setting::{ NetworkSetting, NetworkSettingInput, SecuritySetting, SecuritySettingInput, SettingService, }, }; +use crate::schema::repository::FileEntrySearchResult; pub trait ServiceLocator: Send + Sync { fn auth(&self) -> Arc; @@ -55,6 +58,7 @@ pub trait ServiceLocator: Send + Sync { fn setting(&self) -> Arc; fn license(&self) -> Arc; fn analytic(&self) -> Arc; + fn github_repository_provider(&self) -> Arc; } pub struct Context { @@ -226,6 +230,30 @@ impl Query { .await } + async fn github_repository_providers( + ctx: &Context, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> FieldResult> { + check_admin(ctx).await?; + relay::query_async( + after, + before, + first, + last, + |after, before, first, last| async move { + Ok(ctx + .locator + .github_repository_provider() + .list_github_repository_providers(after, before, first, last) + .await?) + }, + ) + .await + } + async fn job_runs( ctx: &Context, ids: Option>, diff --git a/ee/tabby-webserver/src/service/dao.rs b/ee/tabby-webserver/src/service/dao.rs index 4260f4dc6cbe..402e973540d3 100644 --- a/ee/tabby-webserver/src/service/dao.rs +++ b/ee/tabby-webserver/src/service/dao.rs @@ -2,13 +2,14 @@ use anyhow::anyhow; use hash_ids::HashIds; use lazy_static::lazy_static; use tabby_db::{ - EmailSettingDAO, InvitationDAO, JobRunDAO, OAuthCredentialDAO, RepositoryDAO, ServerSettingDAO, - UserDAO, + EmailSettingDAO, GithubRepositoryProviderDAO, InvitationDAO, JobRunDAO, OAuthCredentialDAO, + RepositoryDAO, ServerSettingDAO, UserDAO, }; use crate::schema::{ auth::{self, OAuthCredential, OAuthProvider}, email::{AuthMethod, EmailSetting, Encryption}, + github_repository_provider::GithubRepositoryProvider, job, repository::Repository, setting::{NetworkSetting, SecuritySetting}, @@ -119,6 +120,16 @@ impl From for NetworkSetting { } } +impl From for GithubRepositoryProvider { + fn from(value: GithubRepositoryProviderDAO) -> Self { + Self { + display_name: value.display_name, + application_id: value.application_id, + id: value.id.as_id(), + } + } +} + lazy_static! { static ref HASHER: HashIds = HashIds::builder() .with_salt("tabby-id-serializer") diff --git a/ee/tabby-webserver/src/service/github_repository_provider.rs b/ee/tabby-webserver/src/service/github_repository_provider.rs new file mode 100644 index 000000000000..5f4970b19443 --- /dev/null +++ b/ee/tabby-webserver/src/service/github_repository_provider.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use juniper::ID; +use tabby_db::DbConn; + +use super::AsRowid; +use crate::{ + schema::{ + github_repository_provider::{GithubRepositoryProvider, GithubRepositoryProviderService}, + Result, + }, + service::graphql_pagination_to_filter, +}; + +struct GithubRepositoryProviderServiceImpl { + db: DbConn, +} + +pub fn new_github_repository_provider_service(db: DbConn) -> impl GithubRepositoryProviderService { + GithubRepositoryProviderServiceImpl { db } +} + +#[async_trait] +impl GithubRepositoryProviderService for GithubRepositoryProviderServiceImpl { + async fn get_github_repository_provider(&self, id: ID) -> Result { + let provider = self.db.get_github_provider(id.as_rowid()?).await?; + Ok(provider.into()) + } + + async fn read_github_repository_provider_secret(&self, id: ID) -> Result { + let provider = self.db.get_github_provider(id.as_rowid()?).await?; + Ok(provider.secret) + } + + async fn update_github_repository_provider_access_token( + &self, + id: ID, + access_token: String, + ) -> Result<()> { + self.db + .update_github_provider_access_token(id.as_rowid()?, access_token) + .await?; + Ok(()) + } + + async fn list_github_repository_providers( + &self, + after: Option, + before: Option, + first: Option, + last: Option, + ) -> Result> { + let (limit, skip_id, backwards) = graphql_pagination_to_filter(after, before, first, last)?; + let providers = self + .db + .list_github_repository_providers(limit, skip_id, backwards) + .await?; + Ok(providers + .into_iter() + .map(GithubRepositoryProvider::from) + .collect()) + } +} diff --git a/ee/tabby-webserver/src/service/mod.rs b/ee/tabby-webserver/src/service/mod.rs index b023f6fd5500..021d39e9fc9b 100644 --- a/ee/tabby-webserver/src/service/mod.rs +++ b/ee/tabby-webserver/src/service/mod.rs @@ -3,6 +3,7 @@ mod auth; mod dao; mod email; pub mod event_logger; +mod github_repository_provider; mod job; mod license; mod proxy; @@ -30,12 +31,14 @@ use tracing::{info, warn}; use self::{ analytic::new_analytic_service, auth::new_authentication_service, email::new_email_service, + github_repository_provider::new_github_repository_provider_service, license::new_license_service, }; use crate::schema::{ analytic::AnalyticService, auth::AuthenticationService, email::EmailService, + github_repository_provider::GithubRepositoryProviderService, job::JobService, license::{IsLicenseValid, LicenseService}, repository::RepositoryService, @@ -52,6 +55,7 @@ struct ServerContext { mail: Arc, auth: Arc, license: Arc, + github_repository_provider: Arc, logger: Arc, code: Arc, @@ -87,6 +91,9 @@ impl ServerContext { license.clone(), )), license, + github_repository_provider: Arc::new(new_github_repository_provider_service( + db_conn.clone(), + )), db_conn, logger, code, @@ -294,6 +301,12 @@ impl ServiceLocator for Arc { fn analytic(&self) -> Arc { new_analytic_service(self.db_conn.clone()) } + + fn github_repository_provider( + &self, + ) -> Arc { + self.github_repository_provider.clone() + } } pub async fn create_service_locator(