diff --git a/.github/workflows/base.yml b/.github/workflows/base.yml index d5c25a93d..91dea4611 100644 --- a/.github/workflows/base.yml +++ b/.github/workflows/base.yml @@ -45,7 +45,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: | sudo apt update - sudo apt install libwebkit2gtk-4.0-dev \ + sudo apt install -y libwebkit2gtk-4.0-dev \ build-essential \ curl \ wget \ diff --git a/Cargo.toml b/Cargo.toml index 8d57bf920..eeece1aff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ axum-server = "0.7" tower-http = "0.5.2" tower = "0.5.0" hex = "0.4.3" -sea-orm = "1.0.0" +sea-orm = "1.0.1" flate2 = "1.0.30" bstr = "1.10.0" colored = "2.1.0" diff --git a/atlas/Cargo.toml b/atlas/Cargo.toml index 7c9eebaa6..0faccf1b1 100644 --- a/atlas/Cargo.toml +++ b/atlas/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -async-openai = "0.24.0" +async-openai = "0.23.4" reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/common/src/config.rs b/common/src/config.rs index 88e1f46bd..60edf441d 100644 --- a/common/src/config.rs +++ b/common/src/config.rs @@ -18,7 +18,9 @@ pub struct Config { pub monorepo: MonoConfig, pub pack: PackConfig, pub lfs: LFSConfig, - pub oauth: OauthConfig, + // Not used in mega app + #[serde(default)] + pub oauth: Option, } impl Config { @@ -277,4 +279,6 @@ impl Default for LFSConfig { pub struct OauthConfig { pub github_client_id: String, pub github_client_secret: String, + pub ui_domain: String, + pub cookie_domain: String, } diff --git a/docker/README.md b/docker/README.md index 472bb1309..c082e7bde 100644 --- a/docker/README.md +++ b/docker/README.md @@ -52,5 +52,5 @@ docker network create mono-network # run postgres docker run --rm -it -d --name mono-pg --network mono-network -v /mnt/data/mono/pg-data:/var/lib/postgresql/data -p 5432:5432 mono-pg:0.1-pre-release docker run --rm -it -d --name mono-engine --network mono-network -v /mnt/data/mono/mono-data:/opt/mega -p 8000:8000 -p 22:9000 mono-engine:0.1-pre-release -docker run --rm -it -d --name mono-ui --network mono-network -e MEGA_INTERNAL_HOST=http://mono-engine:8000 -e MEGA_HOST=http://git.gitmono.com -e MOON_HOST=https://console.gitmono.com -p 3000:3000 mono-ui:0.1-pre-release +docker run --rm -it -d --name mono-ui --network mono-network -e MEGA_INTERNAL_HOST=http://mono-engine:8000 -e MEGA_HOST=https://git.gitmono.com -e MOON_HOST=https://console.gitmono.com -p 3000:3000 mono-ui:0.1-pre-release ``` \ No newline at end of file diff --git a/gateway/src/https_server.rs b/gateway/src/https_server.rs index 4c36f523b..43569cfde 100644 --- a/gateway/src/https_server.rs +++ b/gateway/src/https_server.rs @@ -132,6 +132,8 @@ pub async fn app( inner: MonoApiServiceState { context: context.clone(), common: common.clone(), + oauth_client: None, + store: None, }, ztm, port, @@ -140,6 +142,8 @@ pub async fn app( let mono_api_state = MonoApiServiceState { context: context.clone(), common: common.clone(), + oauth_client: None, + store: None, }; pub fn mega_routers() -> Router { diff --git a/jupiter/callisto/src/lib.rs b/jupiter/callisto/src/lib.rs index 072b64b70..1250d6e9d 100644 --- a/jupiter/callisto/src/lib.rs +++ b/jupiter/callisto/src/lib.rs @@ -25,6 +25,8 @@ pub mod mega_tag; pub mod mega_tree; pub mod mq_storage; pub mod raw_blob; +pub mod ssh_keys; +pub mod user; pub mod ztm_node; pub mod ztm_nostr_event; pub mod ztm_nostr_req; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 1a998eef0..c29eb6397 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -21,6 +21,8 @@ pub use crate::mega_refs::Entity as MegaRefs; pub use crate::mega_tag::Entity as MegaTag; pub use crate::mega_tree::Entity as MegaTree; pub use crate::raw_blob::Entity as RawBlob; +pub use crate::ssh_keys::Entity as SshKeys; +pub use crate::user::Entity as User; pub use crate::ztm_node::Entity as ZtmNode; pub use crate::ztm_path_mapping::Entity as ZtmPathMapping; pub use crate::ztm_repo_info::Entity as ZtmRepoInfo; diff --git a/jupiter/callisto/src/ssh_keys.rs b/jupiter/callisto/src/ssh_keys.rs new file mode 100644 index 000000000..5229eef0c --- /dev/null +++ b/jupiter/callisto/src/ssh_keys.rs @@ -0,0 +1,19 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "ssh_keys")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + pub user_id: i64, + #[sea_orm(column_type = "Text")] + pub ssh_key: String, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/user.rs b/jupiter/callisto/src/user.rs new file mode 100644 index 000000000..af868f098 --- /dev/null +++ b/jupiter/callisto/src/user.rs @@ -0,0 +1,25 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i64, + #[sea_orm(column_type = "Text")] + pub name: String, + #[sea_orm(column_type = "Text", unique)] + pub email: String, + #[sea_orm(column_type = "Text")] + pub avatar_url: String, + pub is_github: bool, + pub created_at: DateTime, + pub updated_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/ztm_path_mapping.rs b/jupiter/callisto/src/ztm_path_mapping.rs index 849ad15d9..e5f21ccc6 100644 --- a/jupiter/callisto/src/ztm_path_mapping.rs +++ b/jupiter/callisto/src/ztm_path_mapping.rs @@ -7,7 +7,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: i64, - #[sea_orm(column_type = "Text")] + #[sea_orm(column_type = "Text", unique)] pub alias: String, #[sea_orm(column_type = "Text")] pub repo_path: String, diff --git a/jupiter/src/context.rs b/jupiter/src/context.rs index 1b836ddb2..c6c583f18 100644 --- a/jupiter/src/context.rs +++ b/jupiter/src/context.rs @@ -4,7 +4,8 @@ use common::config::Config; use crate::storage::{ git_db_storage::GitDbStorage, init::database_connection, lfs_storage::LfsStorage, - mono_storage::MonoStorage, mq_storage::MQStorage, ztm_storage::ZTMStorage, + mono_storage::MonoStorage, mq_storage::MQStorage, user_storage::UserStorage, + ztm_storage::ZTMStorage, }; #[derive(Clone)] @@ -35,6 +36,7 @@ pub struct Service { pub lfs_storage: Arc, pub ztm_storage: Arc, pub mq_storage: Arc, + pub user_storage: UserStorage, } impl Service { @@ -50,6 +52,7 @@ impl Service { lfs_storage: Arc::new(LfsStorage::new(connection.clone()).await), ztm_storage: Arc::new(ZTMStorage::new(connection.clone()).await), mq_storage: Arc::new(MQStorage::new(connection.clone()).await), + user_storage: UserStorage::new(connection.clone()).await, } } @@ -64,6 +67,7 @@ impl Service { lfs_storage: Arc::new(LfsStorage::mock()), ztm_storage: Arc::new(ZTMStorage::mock()), mq_storage: Arc::new(MQStorage::mock()), + user_storage: UserStorage::mock(), }) } } diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index 63b6b3a50..098f9ccad 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -4,13 +4,14 @@ pub mod init; pub mod lfs_storage; pub mod mono_storage; pub mod mq_storage; +pub mod user_storage; pub mod ztm_storage; use async_trait::async_trait; +use sea_orm::{sea_query::OnConflict, ActiveModelTrait, ConnectionTrait, EntityTrait}; use callisto::import_refs; use common::errors::MegaError; -use sea_orm::{sea_query::OnConflict, ActiveModelTrait, ConnectionTrait, EntityTrait}; /// /// This interface is designed to handle the commonalities between the database storage and /// file system storage. @@ -23,7 +24,8 @@ pub trait GitStorageProvider: Send + Sync { async fn get_ref(&self, repo_id: i64) -> Result, MegaError>; - async fn update_ref(&self, repo_id: i64, ref_name: &str, new_id: &str) -> Result<(), MegaError>; + async fn update_ref(&self, repo_id: i64, ref_name: &str, new_id: &str) + -> Result<(), MegaError>; // async fn save_entry(&self, repo: &Repo, entry_list: Vec) -> Result<(), MegaError>; diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs new file mode 100644 index 000000000..91b20bca8 --- /dev/null +++ b/jupiter/src/storage/user_storage.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; + +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, +}; + +use callisto::{ssh_keys, user}; +use common::{errors::MegaError, utils::generate_id}; + +#[derive(Clone)] +pub struct UserStorage { + pub connection: Arc, +} + +impl UserStorage { + pub fn get_connection(&self) -> &DatabaseConnection { + &self.connection + } + + pub async fn new(connection: Arc) -> Self { + UserStorage { connection } + } + + pub fn mock() -> Self { + UserStorage { + connection: Arc::new(DatabaseConnection::default()), + } + } + + pub async fn find_user_by_email(&self, email: &str) -> Result, MegaError> { + let res = user::Entity::find() + .filter(user::Column::Email.eq(email)) + .one(self.get_connection()) + .await?; + Ok(res) + } + + pub async fn save_user(&self, user: user::Model) -> Result<(), MegaError> { + let a_model = user.into_active_model(); + a_model.insert(self.get_connection()).await.unwrap(); + Ok(()) + } + + pub async fn save_ssh_key(&self, user_id: i64, ssh_key: &str) -> Result<(), MegaError> { + let model = ssh_keys::Model { + id: generate_id(), + user_id, + ssh_key: ssh_key.to_owned(), + created_at: chrono::Utc::now().naive_utc(), + }; + let a_model = model.into_active_model(); + a_model.insert(self.get_connection()).await.unwrap(); + Ok(()) + } + + pub async fn list_user_ssh(&self, user_id: i64) -> Result, MegaError> { + let res = ssh_keys::Entity::find() + .filter(ssh_keys::Column::UserId.eq(user_id)) + .all(self.get_connection()) + .await?; + Ok(res) + } + + pub async fn delete_ssh_key(&self, id: i64) -> Result<(), MegaError> { + ssh_keys::Entity::delete_by_id(id) + .exec(self.get_connection()) + .await?; + Ok(()) + } +} diff --git a/mega/config.toml b/mega/config.toml index 3c3060ddf..4742d168d 100644 --- a/mega/config.toml +++ b/mega/config.toml @@ -87,7 +87,3 @@ enable_split = true # Default is disabled. Set to true to enable file splitting # Size of each file chunk when splitting is enabled, in bytes. Ignored if splitting is disabled. split_size = 20971520 # Default size is 20MB (20971520 bytes) -[oauth] -# GitHub OAuth application client id and secret -github_client_id = "" -github_client_secret = "" \ No newline at end of file diff --git a/mono/Cargo.toml b/mono/Cargo.toml index 91f2393dc..fc72008a6 100644 --- a/mono/Cargo.toml +++ b/mono/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] common = { workspace = true } +callisto = { workspace = true } jupiter = { workspace = true } ceres = { workspace = true } taurus = { workspace = true } @@ -50,6 +51,10 @@ ed25519-dalek = { workspace = true, features = ["pkcs8"] } lazy_static = { workspace = true } ctrlc = { workspace = true } shadow-rs = { workspace = true } +oauth2 = "4.4.2" +async-session = "3.0.0" +http = "1.1.0" +sea-orm = { workspace = true, features = [] } [dev-dependencies] reqwest = { workspace = true, features = ["stream", "json"] } diff --git a/mono/config.toml b/mono/config.toml index 23ff251b1..ac752e074 100644 --- a/mono/config.toml +++ b/mono/config.toml @@ -49,6 +49,7 @@ big_obj_threshold = 1024 # set the local path of the project storage raw_obj_local_path = "${base_dir}/objects" +# set the local path of the lfs storage lfs_obj_local_path = "${base_dir}/lfs" obs_access_key = "" @@ -80,6 +81,9 @@ clean_cache_after_decode = true channel_message_size = 1_000_000 [lfs] +# LFS Server url +url = "http://localhost:8000" + ## IMPORTANT: The 'enable_split' feature can only be enabled for new databases. Existing databases do not support this feature. # Enable or disable splitting large files into smaller chunks enable_split = true # Default is disabled. Set to true to enable file splitting. @@ -90,4 +94,10 @@ split_size = 20971520 # Default size is 20MB (20971520 bytes) [oauth] # GitHub OAuth application client id and secret github_client_id = "" -github_client_secret = "" \ No newline at end of file +github_client_secret = "" + +# Used redirect to ui after login +ui_domain = "http://localhost:3000" + +# Set .gitmono.com on Production +cookie_domain = "localhost" \ No newline at end of file diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index 5fc072e7b..890aa9ef7 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -15,6 +15,7 @@ use common::model::CommonResult; use taurus::event::api_request::{ApiRequestEvent, ApiType}; use crate::api::mr_router; +use crate::api::user::user_router; use crate::api::MonoApiServiceState; pub fn routers() -> Router { @@ -26,7 +27,10 @@ pub fn routers() -> Router { .route("/tree", get(get_tree_info)) .route("/blob", get(get_blob_object)); - Router::new().merge(router).merge(mr_router::routers()) + Router::new() + .merge(router) + .merge(mr_router::routers()) + .merge(user_router::routers()) } async fn get_blob_object( diff --git a/mono/src/api/mod.rs b/mono/src/api/mod.rs index 9b11f644b..2b99a4ec5 100644 --- a/mono/src/api/mod.rs +++ b/mono/src/api/mod.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use async_session::MemoryStore; +use axum::extract::FromRef; use ceres::{ api_service::{ import_api_service::ImportApiService, mono_api_service::MonoApiService, ApiHandler, @@ -7,16 +9,39 @@ use ceres::{ protocol::repo::Repo, }; use common::model::CommonOptions; -use jupiter::context::Context; +use jupiter::{context::Context, storage::user_storage::UserStorage}; +use oauth2::basic::BasicClient; pub mod api_router; pub mod mr_router; pub mod oauth; +pub mod user; #[derive(Clone)] pub struct MonoApiServiceState { pub context: Context, pub common: CommonOptions, + pub oauth_client: Option, + // TODO: Remove MemoryStore + pub store: Option, +} + +impl FromRef for MemoryStore { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.store.clone().unwrap() + } +} + +impl FromRef for BasicClient { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.oauth_client.clone().unwrap() + } +} + +impl FromRef for UserStorage { + fn from_ref(state: &MonoApiServiceState) -> Self { + state.context.services.user_storage.clone() + } } impl MonoApiServiceState { diff --git a/mono/src/api/oauth/github.rs b/mono/src/api/oauth/github.rs deleted file mode 100644 index dfaaf9b3a..000000000 --- a/mono/src/api/oauth/github.rs +++ /dev/null @@ -1,76 +0,0 @@ -use axum::async_trait; - -use common::errors::MegaError; -use jupiter::context::Context; - -use crate::api::oauth::model::{AuthorizeParams, GitHubAccessTokenJson, OauthCallbackParams}; -use crate::api::oauth::OauthHandler; - -use super::model::GitHubUserJson; - -#[derive(Clone)] -pub struct GithubOauthService { - pub context: Context, - pub client_id: String, - pub client_secret: String, -} - -const GITHUB_ENDPOINT: &str = "https://github.com"; -const GITHUB_API_ENDPOINT: &str = "https://api.github.com"; - -#[async_trait] -impl OauthHandler for GithubOauthService { - fn authorize_url(&self, params: &AuthorizeParams, state: &str) -> String { - let auth_url = format!( - "{}/login/oauth/authorize?client_id={}&redirect_uri={}&state={}", - GITHUB_ENDPOINT, self.client_id, params.redirect_uri, state - ); - auth_url - } - - async fn access_token( - &self, - params: OauthCallbackParams, - redirect_uri: &str, - ) -> Result { - tracing::debug!("{:?}", params); - // get access_token and user for persist - let url = format!( - "{}/login/oauth/access_token?client_id={}&client_secret={}&code={}&redirect_uri={}", - GITHUB_ENDPOINT, self.client_id, self.client_secret, params.code, redirect_uri - ); - let client = reqwest::Client::new(); - let resp = client - .post(url) - .header("Accept", "application/json") - .send() - .await - .unwrap(); - let access_token = resp - .json::() - .await - .unwrap() - .access_token; - Ok(access_token) - } - - async fn user_info(&self, access_token: &str) -> Result { - let user_url = format!("{}/user", GITHUB_API_ENDPOINT); - let client = reqwest::Client::new(); - let resp = client - .get(user_url) - .header("Authorization", format!("Bearer {}", access_token)) - .header("Accept", "application/json") - .header("User-Agent", format!("Mega/{}", "0.0.1")) - .send() - .await - .unwrap(); - let mut user_info = GitHubUserJson::default(); - if resp.status().is_success() { - user_info = resp.json::().await.unwrap(); - } else { - tracing::error!("github:user_info:err {:?}", resp.text().await.unwrap()); - } - Ok(user_info) - } -} diff --git a/mono/src/api/oauth/mod.rs b/mono/src/api/oauth/mod.rs index 7069b5c2d..1861e21c6 100644 --- a/mono/src/api/oauth/mod.rs +++ b/mono/src/api/oauth/mod.rs @@ -1,127 +1,244 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use axum::async_trait; -use axum::response::Redirect; -use axum::routing::post; +use anyhow::Context; +use async_session::{MemoryStore, Session, SessionStore}; use axum::{ - extract::{Path, Query, State}, - http::StatusCode, + async_trait, + extract::{FromRef, FromRequestParts, Query, State}, + http::{header::SET_COOKIE, HeaderMap}, + response::{IntoResponse, Redirect, Response}, routing::get, - Json, Router, + RequestPartsExt, Router, +}; +use axum_extra::{headers, typed_header::TypedHeaderRejectionReason, TypedHeader}; +use callisto::user; +use chrono::{Duration, Utc}; +use http::{header, request::Parts, StatusCode}; +use jupiter::storage::user_storage::UserStorage; +use oauth2::{ + basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId, + ClientSecret, CsrfToken, RedirectUrl, Scope, TokenResponse, TokenUrl, }; -use axum_extra::headers::authorization::Bearer; -use axum_extra::headers::Authorization; -use axum_extra::TypedHeader; -use tokio::sync::Mutex; -use uuid::Uuid; - -use common::model::CommonResult; -use common::enums::SupportOauthType; -use common::errors::MegaError; -use github::GithubOauthService; -use jupiter::context::Context; -use model::{AuthorizeParams, GitHubUserJson, OauthCallbackParams}; - -pub mod github; + +use common::config::OauthConfig; +use model::{GitHubUserJson, LoginUser, OauthCallbackParams}; + +use crate::api::MonoApiServiceState; + pub mod model; -#[derive(Clone)] -pub struct OauthServiceState { - pub context: Context, - pub sessions: Arc>>, +static COOKIE_NAME: &str = "SESSION"; + +pub fn routers() -> Router { + Router::new() + .route("/github", get(github_auth)) + .route("/authorized", get(login_authorized)) + .route("/logout", get(logout)) } -impl OauthServiceState { - pub fn oauth_handler(&self, ouath_type: SupportOauthType) -> impl OauthHandler { - match ouath_type { - SupportOauthType::GitHub => GithubOauthService { - context: self.context.clone(), - client_id: self.context.config.oauth.github_client_id.clone(), - client_secret: self.context.config.oauth.github_client_secret.clone(), - }, - } +async fn github_auth(State(client): State) -> impl IntoResponse { + // Issue for adding check to this example https://github.com/tokio-rs/axum/issues/2511 + let (auth_url, _csrf_token) = client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("identify".to_string())) + .url(); + Redirect::to(auth_url.as_ref()) +} + +async fn login_authorized( + Query(query): Query, + State(state): State, + State(oauth_client): State, +) -> Result { + let store: MemoryStore = MemoryStore::from_ref(&state); + let config = state.context.config.oauth.unwrap(); + // Get an auth token + let token = oauth_client + .exchange_code(AuthorizationCode::new(query.code.clone())) + .request_async(async_http_client) + .await + .context("failed in sending request request to authorization server")?; + + // Fetch user data + let client = reqwest::Client::new(); + let resp = client + .get("https://api.github.com/user") + .header("User-Agent", format!("Mega/{}", "0.0.1")) + .bearer_auth(token.access_token().secret()) + .send() + .await + .context("failed in sending request to target Url")?; + let mut github_user = GitHubUserJson::default(); + + if resp.status().is_success() { + github_user = resp + .json::() + .await + .context("failed to deserialize response as JSON")?; + } else { + tracing::error!("github:user_info:err {:?}", resp.text().await.unwrap()); + } + + + let user_data: user::Model = github_user.into(); + let user_storage = state.context.services.user_storage.clone(); + let user = user_storage.find_user_by_email(&user_data.email).await.unwrap(); + if user.is_none() { + user_storage.save_user(user_data.clone()).await.unwrap(); } + + // Create a new session filled with user data + let login_user:LoginUser = user_data.into(); + let mut session = Session::new(); + session + .insert("user", &login_user) + .context("failed in inserting serialized value into session")?; + + // Store session and get corresponding cookie + let cookie = store + .store_session(session) + .await + .context("failed to store session")? + .context("unexpected error retrieving cookie value")?; + + // Build the cookie + let cookie = format!( + "{COOKIE_NAME}={cookie}; Domain={}; SameSite=Lax; Path=/", + config.cookie_domain + ); + // Set cookie + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + cookie.parse().context("failed to parse cookie")?, + ); + + Ok((headers, Redirect::to(&config.ui_domain))) } -#[async_trait] -pub trait OauthHandler: Send + Sync { - fn authorize_url(&self, params: &AuthorizeParams, state: &str) -> String; +async fn logout( + State(state): State, + TypedHeader(cookies): TypedHeader, +) -> Result { + let store: MemoryStore = MemoryStore::from_ref(&state); + let config = state.context.config.oauth.unwrap(); + let cookie = cookies + .get(COOKIE_NAME) + .context("unexpected error getting cookie name")?; + let mut headers = HeaderMap::new(); - async fn access_token( - &self, - params: OauthCallbackParams, - redirect_uri: &str, - ) -> Result; + let session = match store + .load_session(cookie.to_string()) + .await + .context("failed to load session")? + { + Some(s) => s, + // No session active, just redirect + None => return Ok((headers, Redirect::to(&config.ui_domain))), + }; - async fn user_info(&self, access_token: &str) -> Result; + store + .destroy_session(session) + .await + .context("failed to destroy session")?; + + // Expire cookie + let cookie = format!( + "{COOKIE_NAME}={cookie}; Expires={} Domain={}; SameSite=Lax; Path=/", + config.cookie_domain, + (Utc::now() - Duration::days(1)).to_rfc2822(), + ); + headers.insert( + SET_COOKIE, + cookie.parse().context("failed to parse cookie")?, + ); + Ok((headers, Redirect::to(&config.ui_domain))) } -pub fn routers() -> Router { - Router::new() - .route("/:oauth_type/authorize", get(redirect_authorize)) - .route("/:oauth_type/callback", post(oauth_callback)) - .route("/:oauth_type/user", get(user)) +pub fn oauth_client(oauth_config: OauthConfig) -> Result { + let client_id = oauth_config.github_client_id; + let client_secret = oauth_config.github_client_secret; + let ui_domain = oauth_config.ui_domain; + + let redirect_url = format!("{}/auth/authorized", ui_domain); + + let auth_url = "https://github.com/login/oauth/authorize".to_string(); + + let token_url = "https://github.com/login/oauth/access_token".to_string(); + + Ok(BasicClient::new( + ClientId::new(client_id), + Some(ClientSecret::new(client_secret)), + AuthUrl::new(auth_url).context("failed to create new authorization server URL")?, + Some(TokenUrl::new(token_url).context("failed to create new token endpoint URL")?), + ) + .set_redirect_uri( + RedirectUrl::new(redirect_url).context("failed to create new redirection URL")?, + )) } -async fn redirect_authorize( - Path(oauth_type): Path, - Query(query): Query, - service_state: State, -) -> Result { - let oauth_type: SupportOauthType = match oauth_type.parse::() { - Ok(value) => value, - Err(err) => return Err((StatusCode::BAD_REQUEST, err)), - }; +pub struct AuthRedirect; - let mut sessions = service_state.sessions.lock().await; - let state = Uuid::new_v4().to_string(); - sessions.insert(state.clone(), query.redirect_uri.clone()); - let auth_url = service_state - .oauth_handler(oauth_type) - .authorize_url(&query, &state); - Ok(Redirect::temporary(&auth_url)) +impl IntoResponse for AuthRedirect { + fn into_response(self) -> Response { + (StatusCode::UNAUTHORIZED, "Login in first").into_response() + } } -async fn oauth_callback( - Path(oauth_type): Path, - Query(query): Query, - service_state: State, -) -> Result>, (StatusCode, String)> { - let oauth_type: SupportOauthType = match oauth_type.parse::() { - Ok(value) => value, - Err(err) => return Err((StatusCode::BAD_REQUEST, err)), - }; - // chcek state, - // TODO storage can be replaced by redis, otherwise invalid state can't be expired - let mut sessions = service_state.sessions.lock().await; +#[async_trait] +impl FromRequestParts for LoginUser +where + MemoryStore: FromRef, + UserStorage: FromRef, + S: Send + Sync, +{ + // If anything goes wrong or no session is found, redirect to the auth page + type Rejection = AuthRedirect; - let redirect_uri = match sessions.get(&query.state) { - Some(uri) => uri.clone(), - None => return Ok(Json(CommonResult::failed("Invalid state"))), - }; - let access_token = service_state - .oauth_handler(oauth_type) - .access_token(query.clone(), &redirect_uri) - .await - .unwrap(); - sessions.remove(&query.state); - Ok(Json(CommonResult::success(Some(access_token)))) + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let store = MemoryStore::from_ref(state); + + let cookies = parts + .extract::>() + .await + .map_err(|e| match *e.name() { + header::COOKIE => match e.reason() { + TypedHeaderRejectionReason::Missing => AuthRedirect, + _ => panic!("unexpected error getting Cookie header(s): {e}"), + }, + _ => panic!("unexpected error getting cookies: {e}"), + })?; + let session_cookie = cookies.get(COOKIE_NAME).ok_or(AuthRedirect)?; + + let session = store + .load_session(session_cookie.to_string()) + .await + .unwrap() + .ok_or(AuthRedirect)?; + + let user = session.get::("user").ok_or(AuthRedirect)?; + + Ok(user) + } } -async fn user( - Path(oauth_type): Path, - TypedHeader(Authorization::(token)): TypedHeader>, - service_state: State, -) -> Result, (StatusCode, String)> { - let oauth_type: SupportOauthType = match oauth_type.parse::() { - Ok(value) => value, - Err(err) => return Err((StatusCode::BAD_REQUEST, err)), - }; - let res = service_state - .oauth_handler(oauth_type) - .user_info(token.token()) - .await - .unwrap(); - Ok(Json(res)) +// Use anyhow, define error and enable '?' +// For a simplified example of using anyhow in axum check /examples/anyhow-error-response +#[derive(Debug)] +pub struct OauthError(anyhow::Error); + +impl IntoResponse for OauthError { + fn into_response(self) -> Response { + tracing::error!("Application error: {:#}", self.0); + (StatusCode::INTERNAL_SERVER_ERROR, "Login in first").into_response() + } +} + +// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into +// `Result<_, OauthError>`. That way you don't need to do that manually. +impl From for OauthError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } } diff --git a/mono/src/api/oauth/model.rs b/mono/src/api/oauth/model.rs index 14e550596..b0d7384bf 100644 --- a/mono/src/api/oauth/model.rs +++ b/mono/src/api/oauth/model.rs @@ -1,9 +1,8 @@ +use sea_orm::entity::prelude::*; +use common::utils::generate_id; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] -pub struct AuthorizeParams { - pub redirect_uri: String, -} +use callisto::user; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct OauthCallbackParams { @@ -11,7 +10,6 @@ pub struct OauthCallbackParams { pub state: String, } - #[derive(Serialize, Deserialize, Clone, Debug)] pub struct GitHubAccessTokenJson { pub access_token: String, @@ -26,3 +24,39 @@ pub struct GitHubUserJson { pub avatar_url: String, pub email: String, } + +impl From for user::Model { + fn from(value: GitHubUserJson) -> Self { + Self { + id: generate_id(), + name: value.login, + email: value.email, + avatar_url: value.avatar_url, + is_github: true, + created_at: chrono::Utc::now().naive_utc(), + updated_at: None, + } + } +} + + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct LoginUser { + pub user_id: i64, + pub name: String, + pub avatar_url: String, + pub email: String, + pub created_at: DateTime, +} + +impl From for LoginUser { + fn from(value: user::Model) -> Self { + Self { + user_id: value.id, + name: value.name, + avatar_url: value.avatar_url, + email: value.email, + created_at: value.created_at, + } + } +} diff --git a/mono/src/api/user/mod.rs b/mono/src/api/user/mod.rs new file mode 100644 index 000000000..0fba9e865 --- /dev/null +++ b/mono/src/api/user/mod.rs @@ -0,0 +1,2 @@ +pub mod user_router; +pub mod model; \ No newline at end of file diff --git a/mono/src/api/user/model.rs b/mono/src/api/user/model.rs new file mode 100644 index 000000000..7edc6c363 --- /dev/null +++ b/mono/src/api/user/model.rs @@ -0,0 +1,22 @@ +use callisto::ssh_keys; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct AddSSHKey { + pub ssh_key: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ListSSHKey { + pub id: i64, + pub ssh_key: String, +} + +impl From for ListSSHKey { + fn from(value: ssh_keys::Model) -> Self { + Self { + id: value.id, + ssh_key: value.ssh_key, + } + } +} diff --git a/mono/src/api/user/user_router.rs b/mono/src/api/user/user_router.rs new file mode 100644 index 000000000..8496cc1e2 --- /dev/null +++ b/mono/src/api/user/user_router.rs @@ -0,0 +1,81 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; + +use common::model::CommonResult; + +use crate::api::oauth::model::LoginUser; +use crate::api::user::model::AddSSHKey; +use crate::api::user::model::ListSSHKey; +use crate::api::MonoApiServiceState; + +pub fn routers() -> Router { + Router::new() + .route("/user", get(user)) + .route("/user/ssh", post(add_key)) + .route("/user/ssh/:key_id/delete", post(remove_key)) + .route("/user/ssh/list", get(list_key)) +} + +async fn user( + user: LoginUser, + _: State, +) -> Result>, (StatusCode, String)> { + Ok(Json(CommonResult::success(Some(user)))) +} + +async fn add_key( + user: LoginUser, + state: State, + Json(json): Json, +) -> Result>, (StatusCode, String)> { + let res = state + .context + .services + .user_storage + .save_ssh_key(user.user_id, &json.ssh_key) + .await; + let res = match res { + Ok(_) => CommonResult::success(None), + Err(err) => CommonResult::failed(&err.to_string()), + }; + Ok(Json(res)) +} + +async fn remove_key( + _: LoginUser, + state: State, + Path(key_id): Path, +) -> Result>, (StatusCode, String)> { + let res = state + .context + .services + .user_storage + .delete_ssh_key(key_id) + .await; + let res = match res { + Ok(_) => CommonResult::success(None), + Err(err) => CommonResult::failed(&err.to_string()), + }; + Ok(Json(res)) +} + +async fn list_key( + user: LoginUser, + state: State, +) -> Result>>, (StatusCode, String)> { + let res = state + .context + .services + .user_storage + .list_user_ssh(user.user_id) + .await; + let res = match res { + Ok(data) => CommonResult::success(Some(data.into_iter().map(|x| x.into()).collect())), + Err(err) => CommonResult::failed(&err.to_string()), + }; + Ok(Json(res)) +} diff --git a/mono/src/commands/service/multi.rs b/mono/src/commands/service/multi.rs index bc28041b9..33a28a2f7 100644 --- a/mono/src/commands/service/multi.rs +++ b/mono/src/commands/service/multi.rs @@ -69,7 +69,7 @@ pub(crate) async fn exec(config: Config, args: &ArgMatches) -> MegaResult { }; tokio::spawn(async move { https_server::start_https(config_clone, https).await }) } else { - tokio::task::spawn(async {}) + panic!("start params should provide! run like 'mega service multi http https'") }; let ssh_server = if service_type.contains(&StartCommand::Ssh) { diff --git a/mono/src/server/https_server.rs b/mono/src/server/https_server.rs index 602b75b10..9359aca71 100644 --- a/mono/src/server/https_server.rs +++ b/mono/src/server/https_server.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::net::SocketAddr; use std::ops::Deref; use std::path::PathBuf; @@ -6,6 +5,7 @@ use std::str::FromStr; use std::sync::Arc; use anyhow::Result; +use async_session::MemoryStore; use axum::body::Body; use axum::extract::{Query, State}; use axum::http::{self, Request, StatusCode, Uri}; @@ -16,7 +16,6 @@ use axum_server::tls_rustls::RustlsConfig; use clap::Args; use lazy_static::lazy_static; use regex::Regex; -use tokio::sync::Mutex; use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; use tower_http::decompression::RequestDecompressionLayer; @@ -30,7 +29,7 @@ use jupiter::context::Context; use jupiter::raw_storage::local_storage::LocalStorage; use crate::api::api_router::{self}; -use crate::api::oauth::{self, OauthServiceState}; +use crate::api::oauth::{self, oauth_client}; use crate::api::MonoApiServiceState; use crate::lfs; @@ -137,6 +136,8 @@ pub async fn app(config: Config, host: String, port: u16, common: CommonOptions) let api_state = MonoApiServiceState { context: context.clone(), common: common.clone(), + oauth_client: Some(oauth_client(context.config.oauth.unwrap()).unwrap()), + store: Some(MemoryStore::new()), }; // add RequestDecompressionLayer for handle gzip encode @@ -149,10 +150,7 @@ pub async fn app(config: Config, host: String, port: u16, common: CommonOptions) ) .nest( "/auth", - oauth::routers().with_state(OauthServiceState { - context, - sessions: Arc::new(Mutex::new(HashMap::new())), - }), + oauth::routers().with_state(api_state.clone()), ) // Using Regular Expressions for Path Matching in Protocol .route( diff --git a/moon/next.config.js b/moon/next.config.js index d9f02c93a..96885d902 100644 --- a/moon/next.config.js +++ b/moon/next.config.js @@ -12,6 +12,25 @@ const nextConfig = { }, ], }, + async redirects() { + return [ + { + source: '/auth/github', + destination: process.env.MEGA_HOST + '/auth/github', + permanent: false, + }, + { + source: '/auth/authorized', + destination: process.env.MEGA_HOST + '/auth/authorized', + permanent: false, + }, + { + source: '/auth/logout', + destination: process.env.MEGA_HOST + '/auth/logout', + permanent: false, + } + ] + }, reactStrictMode: true, transpilePackages: [ // antd & deps diff --git a/moon/src/app/actions.ts b/moon/src/app/actions.ts index 3f0ab845a..873f560e7 100644 --- a/moon/src/app/actions.ts +++ b/moon/src/app/actions.ts @@ -21,6 +21,13 @@ export async function get_access_token() { return token } + +export async function get_session() { + const cookieStore = cookies() + const session = cookieStore.get('SESSION') + return session +} + // TODO encrypt access_token function encrypt(sessionData) { return sessionData diff --git a/moon/src/app/api/auth/github/user/route.ts b/moon/src/app/api/auth/github/user/route.ts deleted file mode 100644 index 70352cc4e..000000000 --- a/moon/src/app/api/auth/github/user/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -// import { type NextRequest } from 'next/server' - -import { cookies } from "next/headers"; - -export async function GET(request: Request) { - const endpoint = process.env.MEGA_INTERNAL_HOST; - const cookieStore = cookies(); - const access_token = cookieStore.get('access_token'); - - const res = await fetch(`${endpoint}/auth/github/user`, { - next: { revalidate: 300 }, - headers: { - 'Authorization': `Bearer ${access_token?.value}`, - 'Content-Type': 'application/json', - }, - }) - - const data = await res.json() - - return Response.json({ data }) -} \ No newline at end of file diff --git a/moon/src/app/api/env/route.ts b/moon/src/app/api/env/route.ts deleted file mode 100644 index 4ffdbc1d9..000000000 --- a/moon/src/app/api/env/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const revalidate = 0 -export const dynamic = 'force-dynamic' // defaults to auto - -export async function GET() { - return new Response(JSON.stringify({ - mega_host: process.env.MEGA_HOST, - callback_url: process.env.CALLBACK_URL, - }), { - headers: { 'Content-Type': 'application/json' } - }); -} \ No newline at end of file diff --git a/moon/src/app/api/user/route.ts b/moon/src/app/api/user/route.ts new file mode 100644 index 000000000..13876e350 --- /dev/null +++ b/moon/src/app/api/user/route.ts @@ -0,0 +1,15 @@ +import { type NextRequest } from 'next/server' +export const revalidate = 0 +export const dynamic = 'force-dynamic' // defaults to auto + +export async function GET(request: NextRequest) { + const endpoint = process.env.MEGA_INTERNAL_HOST; + const cookieHeader = request.headers.get('cookie') || ''; + const res = await fetch(`${endpoint}/api/v1/user`, { + headers: { + 'Cookie': cookieHeader, + }, + }) + const data = await res.json() + return Response.json({ data }) +} \ No newline at end of file diff --git a/moon/src/app/api/auth/github/callback/route.ts b/moon/src/app/api/user/ssh/route.ts similarity index 52% rename from moon/src/app/api/auth/github/callback/route.ts rename to moon/src/app/api/user/ssh/route.ts index 9aaab7764..d6f311035 100644 --- a/moon/src/app/api/auth/github/callback/route.ts +++ b/moon/src/app/api/user/ssh/route.ts @@ -1,19 +1,20 @@ - -// import { cookies } from "next/headers"; import { type NextRequest } from 'next/server' +export const revalidate = 0 +export const dynamic = 'force-dynamic' // defaults to auto export async function POST(request: NextRequest) { const endpoint = process.env.MEGA_INTERNAL_HOST; - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get("code"); - const state = searchParams.get("state"); - const res = await fetch(`${endpoint}/auth/github/callback?code=${code}&state=${state}`, { - method: 'POST', + const cookieHeader = request.headers.get('cookie') || ''; + const body = await request.json(); + + const res = await fetch(`${endpoint}/api/v1/user/ssh`, { headers: { + 'Cookie': cookieHeader, 'Content-Type': 'application/json', }, + method: 'POST', + body: body, }) const data = await res.json() - return Response.json({ data }) } \ No newline at end of file diff --git a/moon/src/app/application-layout.tsx b/moon/src/app/application-layout.tsx index 6cf7b67ba..42e04e184 100644 --- a/moon/src/app/application-layout.tsx +++ b/moon/src/app/application-layout.tsx @@ -42,13 +42,13 @@ import { } from '@heroicons/react/20/solid' import { Button } from '@/components/catalyst/button' import { useState, useEffect } from 'react' -import { get_access_token } from '@/app/actions' +import { get_session } from '@/app/actions' import { usePathname } from 'next/navigation' function AccountDropdownMenu({ anchor }: { anchor: 'top start' | 'bottom end' }) { return ( - + My account @@ -62,7 +62,7 @@ function AccountDropdownMenu({ anchor }: { anchor: 'top start' | 'bottom end' }) Share feedback - + Sign out @@ -72,8 +72,8 @@ function AccountDropdownMenu({ anchor }: { anchor: 'top start' | 'bottom end' }) interface User { avatar_url: string; - login: string; - id: number; + name: string; + user_id: number; email: string; } @@ -83,29 +83,29 @@ export function ApplicationLayout({ children: React.ReactNode }) { let pathname = usePathname() - const [token, setToken] = useState(""); + + const [session, setSession] = useState(""); const [user, setUser] = useState(null); + useEffect(() => { - fetch_token() - async function fetch_token() { - let res = await get_access_token(); + fetch_session() + async function fetch_session() { + let res = await get_session(); if (res?.value) { - let token_value = res?.value; - setToken(token_value) + let val = res?.value; + setSession(val) } } - }, []) - useEffect(() => { - if (token) { - fetchMessage(); + if (session) { + fetchUser(); } - async function fetchMessage() { - const response = await fetch('/api/auth/github/user'); + async function fetchUser() { + const response = await fetch('/api/user'); const user = await response.json(); - setUser(user.data); + setUser(user.data.data); } - }, [token]) + }, [session]) return ( { - !token && + !session && } { @@ -202,7 +202,7 @@ export function ApplicationLayout({ - {user.login} + {user.name} {user.email} diff --git a/moon/src/app/auth/github/callback/page.tsx b/moon/src/app/auth/github/callback/page.tsx deleted file mode 100644 index f0a282fa0..000000000 --- a/moon/src/app/auth/github/callback/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client' - -import { handleLogin } from '@/app/actions'; -import { useCallback, useEffect } from 'react'; -import { message } from "antd"; - -export default function AuthPage({ searchParams }) { - const [messageApi, contextHolder] = message.useMessage(); - - - const error = useCallback((err_msg) => { - messageApi.open({ - type: 'error', - content: err_msg, - }); - }, [messageApi]); - const code = searchParams.code; - const state = searchParams.state; - - useEffect(() => { - async function fetchData() { - try { - const res = await fetch(`/api/auth/github/callback?code=${code}&state=${state}`, { - method: 'POST', - }); - if (!res.ok) { - throw new Error('Failed to fetch data'); - } - const result = await res.json(); - let data = result.data; - if (data.req_result) { - handleLogin(data.data) - } else { - error(data.err_message) - } - } catch (error) { - console.error('Error fetching data:', error); - } - } - if (code && state) { - fetchData(); - } - }, [code, state, error]); - return <> - {contextHolder} - -} diff --git a/moon/src/app/login/page.tsx b/moon/src/app/login/page.tsx index 6413d321b..f4e3c7adf 100644 --- a/moon/src/app/login/page.tsx +++ b/moon/src/app/login/page.tsx @@ -2,8 +2,6 @@ import Image from "next/image"; export const revalidate = 0 export default async function Login() { - const res = await fetch(`http://localhost:3000/api/env`); - const response = await res.json(); return ( <>
@@ -48,22 +46,19 @@ export default async function Login() { Google - { - response && - - - GitHub - - } + + + GitHub +
diff --git a/moon/src/app/page.tsx b/moon/src/app/page.tsx index 52eb13d65..8defa5a21 100644 --- a/moon/src/app/page.tsx +++ b/moon/src/app/page.tsx @@ -7,7 +7,7 @@ export default async function HomePage() { return (
- +
); } diff --git a/moon/src/app/tree/[...path]/page.tsx b/moon/src/app/tree/[...path]/page.tsx index fcff29a31..ce5953232 100644 --- a/moon/src/app/tree/[...path]/page.tsx +++ b/moon/src/app/tree/[...path]/page.tsx @@ -4,6 +4,7 @@ import CodeTable from '@/components/CodeTable' import Bread from '@/components/BreadCrumb' import RepoTree from '@/components/RepoTree' import { useEffect, useState } from 'react' +import { Flex, Layout } from "antd/lib"; export default function Page({ params }: { params: { path: string[] } }) { const [directory, setDirectory] = useState([]); @@ -23,12 +24,41 @@ export default function Page({ params }: { params: { path: string[] } }) { fetchData(); }, [path]); + const treeStyle = { + borderRadius: 8, + overflow: 'hidden', + width: 'calc(20% - 8px)', + maxWidth: 'calc(20% - 8px)', + background: '#fff', + }; + + const codeStyle = { + borderRadius: 8, + overflow: 'hidden', + width: 'calc(80% - 8px)', + background: '#fff', + }; + + const breadStyle = { + minHeight: 30, + borderRadius: 8, + overflow: 'hidden', + width: 'calc(100% - 8px)', + background: '#fff', + }; + return ( -
- - - -
+ + + + + + + + + + + ); } diff --git a/moon/src/app/user/profile/page.tsx b/moon/src/app/user/profile/page.tsx new file mode 100644 index 000000000..cd0e2b2eb --- /dev/null +++ b/moon/src/app/user/profile/page.tsx @@ -0,0 +1,66 @@ +'use client' + +import { Divider } from '@/components/catalyst/divider' +import { Heading, Subheading } from '@/components/catalyst/heading' +import { useState } from 'react' +import { Input, Button } from "antd"; + +const { TextArea } = Input; + +export default function Settings() { + const [ssh_key, setSSHKey] = useState(''); + + return ( +
+ Add new SSH Key + + +
+
+ Title +
+
+ +
+
+ +
+
+
+
+ Key +
+
+
+ +