feat: update
All checks were successful
continuous-integration/drone/push Build is passing

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2024-04-10 20:50:48 +02:00
parent ec8d0b5ebc
commit 74ea9ddf79
35 changed files with 5 additions and 11 deletions

View File

@@ -0,0 +1,33 @@
[package]
name = "scel_api"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.5.17" }
axum-extra = { version = "0.3.7", features = ["spa"] }
futures = "0.3.28"
tower-http = { version = "0.3.4", features = ["cors", "trace"] }
async-graphql = { version = "4.0.16", features = [
'tracing',
'opentelemetry',
"log",
] }
async-graphql-axum = { version = "4.0.16" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.89"
tokio = { version = "1.22", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3" }
anyhow = { version = "1.0.66" }
oauth2 = { version = "*" }
async-session = { version = "*" }
reqwest = { version = "*", default-features = false, features = [
"rustls-tls",
"json",
] }
hyper = { version = "*" }
scel_core = { path = "../scel_core" }

View File

@@ -0,0 +1,99 @@
use std::env;
use async_session::{MemoryStore, Session, SessionStore};
use axum::{
extract::Query,
http::HeaderMap,
response::{IntoResponse, Redirect},
Extension,
};
use oauth2::{
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
ClientSecret, CsrfToken, RedirectUrl, TokenResponse, TokenUrl,
};
use reqwest::header::SET_COOKIE;
use serde::Deserialize;
use crate::{User, COOKIE_NAME};
pub fn oauth_client() -> BasicClient {
let client_id = env::var("GITEA_CLIENT_ID").expect("Missing GITEA_CLIENT_ID");
let client_secret = env::var("GITEA_CLIENT_SECRET").expect("Missing GITEA_CLIENT_SECRET");
let redirect_url = env::var("GITEA_REDIRECT_URL")
.unwrap_or_else(|_| "http://127.0.0.1:3000/auth/authorized".to_string());
let auth_url =
env::var("GITEA_AUTH_URL").unwrap_or_else(|_| "https://git.front.kjuulh.io".to_string());
let token_url =
env::var("GITEA_TOKEN_URL").unwrap_or_else(|_| "https://git.front.kjuulh.io".to_string());
BasicClient::new(
ClientId::new(client_id),
Some(ClientSecret::new(client_secret)),
AuthUrl::new(auth_url).expect("AuthUrl was invalid"),
Some(TokenUrl::new(token_url).expect("Token url was invalid")),
)
.set_redirect_uri(RedirectUrl::new(redirect_url).expect("RedirectUrl was invalid"))
}
pub async fn gitea(Extension(client): Extension<BasicClient>) -> impl IntoResponse {
let (auth_url, _crsf_token) = client.authorize_url(CsrfToken::new_random).url();
Redirect::to(&auth_url.to_string())
}
#[derive(Debug, Deserialize)]
pub struct AuthRequest {
code: String,
state: String,
}
pub async fn authorized(
Query(query): Query<AuthRequest>,
Extension(store): Extension<MemoryStore>,
Extension(oauth_client): Extension<BasicClient>,
) -> impl IntoResponse {
let token = oauth_client
.exchange_code(AuthorizationCode::new(query.code.clone()))
.request_async(async_http_client)
.await
.expect("failed to get http client");
let client = reqwest::Client::new();
let user_data_json = client
.get(get_gitea_user_data_url())
.bearer_auth(token.access_token().secret())
.send()
.await
.expect("Request did not succeed");
// .text()
// .await
// .unwrap();
let user_data: User = user_data_json
.json::<User>()
.await
.expect("could not parse user");
let mut session = Session::new();
session
.insert("user", &user_data)
.expect("could not insert user data");
let cookie = store
.store_session(session)
.await
.expect("could not insert session")
.expect("session was not valid");
let cookie = format!("{}={}; SameSite=Lax; Path=/", COOKIE_NAME, cookie);
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, cookie.parse().expect("Cookie is not valid"));
(headers, Redirect::to("/"))
}
fn get_gitea_user_data_url() -> String {
env::var("GITEA_USER_INFO_URL").expect("Missing GITEA_USER_INFO_URL")
}

View File

@@ -0,0 +1,4 @@
pub mod mutation;
pub mod query;
pub mod schema;
pub mod subscription;

View File

@@ -0,0 +1,38 @@
use std::sync::Arc;
use async_graphql::{Context, Object, Result, SimpleObject, ID};
use scel_core::{services::Download, App};
pub struct MutationRoot;
#[derive(SimpleObject)]
struct RequestDownloadResponse {
id: ID,
}
#[Object]
impl MutationRoot {
async fn request_download(
&self,
ctx: &Context<'_>,
download_link: String,
) -> Result<RequestDownloadResponse> {
let app = ctx.data_unchecked::<Arc<App>>();
let download = app
.download_service
.clone()
.add_download(Download {
id: None,
link: download_link,
progress: None,
file_name: None,
})
.await
.unwrap();
Ok(RequestDownloadResponse {
id: download.id.unwrap().into(),
})
}
}

View File

@@ -0,0 +1,32 @@
use std::sync::Arc;
use async_graphql::{Context, Object, Result, SimpleObject, ID};
use scel_core::App;
#[derive(SimpleObject, Clone)]
pub struct Download {
pub id: ID,
pub link: String,
pub progress: Option<u32>,
pub file_name: Option<String>,
}
pub struct QueryRoot;
#[Object]
impl QueryRoot {
async fn get_download(&self, ctx: &Context<'_>, id: ID) -> Result<Option<Download>> {
let app = ctx.data_unchecked::<Arc<App>>();
match app.download_service.get_download(id.to_string()).await {
Ok(Some(d)) => Ok(Some(Download {
id: ID::from(d.id.expect("ID could not be found")),
progress: None,
link: d.link,
file_name: None,
})),
Ok(None) => Ok(None),
Err(e) => Err(e.into()),
}
}
}

View File

@@ -0,0 +1,5 @@
use async_graphql::Schema;
use super::{mutation::MutationRoot, query::QueryRoot, subscription::SubscriptionRoot};
pub type ScelSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;

View File

@@ -0,0 +1,49 @@
use std::sync::Arc;
use async_graphql::{
async_stream::stream, futures_util::Stream, Context, Object, Subscription, ID,
};
use scel_core::App;
use super::query::Download;
pub struct SubscriptionRoot;
struct DownloadChanged {
download: Download,
}
#[Object]
impl DownloadChanged {
async fn download(&self) -> Download {
self.download.clone()
}
}
#[Subscription]
impl SubscriptionRoot {
async fn get_download(&self, ctx: &Context<'_>, id: ID) -> impl Stream<Item = DownloadChanged> {
let app = ctx.data_unchecked::<Arc<App>>();
let mut stream = app
.download_service
.subscribe_download(id.to_string())
.await;
stream! {
while stream.changed().await.is_ok() {
let next_download = (*stream.borrow()).clone();
let id = ID::from(next_download.id.unwrap());
yield DownloadChanged {
download: Download {
id: id,
link: next_download.link,
file_name: next_download.file_name,
progress: next_download.progress,
}
}
}
}
}
}

173
crates/scel_api/src/lib.rs Normal file
View File

@@ -0,0 +1,173 @@
mod auth;
mod graphql;
use std::{io, net::SocketAddr, sync::Arc};
use async_graphql::{
extensions::{Logger, Tracing},
http::{playground_source, GraphQLPlaygroundConfig},
Request, Response, Schema,
};
use async_graphql_axum::GraphQLSubscription;
use async_session::{async_trait, MemoryStore, SessionStore};
use auth::{authorized, gitea};
use axum::{
extract::{rejection::TypedHeaderRejectionReason, FromRequest, RequestParts},
headers,
http::{header, Method},
response::{Html, IntoResponse, Redirect},
routing::{self, get_service},
Extension, Json, Router, TypedHeader,
};
use graphql::{
mutation::MutationRoot, query::QueryRoot, schema::ScelSchema, subscription::SubscriptionRoot,
};
use reqwest::StatusCode;
use scel_core::App;
use serde::{Deserialize, Serialize};
use tower_http::{
cors::CorsLayer,
services::ServeDir,
trace::{DefaultMakeSpan, TraceLayer},
};
async fn graphql_playground() -> impl IntoResponse {
Html(playground_source(
GraphQLPlaygroundConfig::new("/graphql").subscription_endpoint("/ws"),
))
}
async fn graphql_handler(
schema: Extension<ScelSchema>,
req: Json<Request>,
_: User,
) -> Json<Response> {
schema.execute(req.0).await.into()
}
pub struct Server {
app: Router,
addr: SocketAddr,
}
impl Server {
pub fn new(app: Arc<App>) -> Server {
let schema = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
.extension(Tracing)
.extension(Logger)
.data(app)
.finish();
let cors = vec![
"http://localhost:3000"
.parse()
.expect("Could not parse url"),
"https://scel.front.kjuulh.io"
.parse()
.expect("Could not parse url"),
];
let api_router = Router::new()
.route(
"/graphql",
routing::get(graphql_playground).post(graphql_handler),
)
.route("/ws", GraphQLSubscription::new(schema.clone()))
.route("/auth/gitea", routing::get(gitea))
.route("/auth/authorized", routing::get(authorized))
// .merge(axum_extra::routing::SpaRouter::new(
// "/assets",
// "src/web/dist/assets",
// ))
.fallback(get_service(ServeDir::new("./src/web/dist/")).handle_error(handle_error))
.layer(Extension(schema))
.layer(Extension(MemoryStore::new()))
.layer(Extension(auth::oauth_client()))
.layer(
CorsLayer::new()
.allow_origin(cors)
.allow_headers([axum::http::header::CONTENT_TYPE])
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]),
)
.layer(TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::default()));
let app = Router::new().nest("/api", api_router);
let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
Server { app, addr }
}
pub async fn start(self) -> anyhow::Result<()> {
tracing::info!("listening on {}", self.addr);
match axum::Server::bind(&self.addr)
.serve(self.app.into_make_service())
.await
{
Ok(_) => Ok(()),
Err(e) => Err(e.into()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct User {
#[serde(alias = "sub")]
id: String,
#[serde(alias = "picture")]
avatar: Option<String>,
#[serde(alias = "email")]
email: String,
#[serde(alias = "preferred_username")]
username: String,
}
struct AuthRedirect;
impl IntoResponse for AuthRedirect {
fn into_response(self) -> axum::response::Response {
Redirect::temporary("/auth/gitea").into_response()
}
}
const COOKIE_NAME: &str = "auth";
#[async_trait]
impl<B> FromRequest<B> for User
where
B: Send,
{
type Rejection = AuthRedirect;
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let Extension(store) = Extension::<MemoryStore>::from_request(req)
.await
.expect("MemoryStore extension is missing");
let cookies = TypedHeader::<headers::Cookie>::from_request(req)
.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
.expect("could not load session")
.ok_or(AuthRedirect)?;
let user = session.get::<User>("user").ok_or(AuthRedirect)?;
Ok(user)
}
}
async fn handle_error(_err: io::Error) -> impl IntoResponse {
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong...")
}