Compare commits

...

10 Commits

55 changed files with 6758 additions and 2 deletions

1
.env_sample Normal file
View File

@ -0,0 +1 @@
export POSTGRES_DATABASE_URL="postgres://postgres:password@localhost:5432/postgres"

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
.env

3419
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,32 @@ name = "forgeflux"
version = "0.1.0"
edition = "2021"
[workspace]
exclude = ["utils/db-migrations"] #, "utils/cache-bust"]
memebers = ["."]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4.5.1"
async-trait = "0.1.80"
chrono = "0.4.38"
config = "0.14.0"
derive_more = "0.99.17"
lazy_static = "1.4.0"
log = "0.4.21"
pretty_env_logger = "0.5.0"
rand = "0.8.5"
reqwest = { version = "0.12.4", features = ["json"] }
rust-embed = { version = "8.3.0", features = ["include-exclude"] }
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.116"
sqlx = { version = "0.7.4", features = ["runtime-tokio-rustls", "postgres", "time"] }
tera = "1.19.1"
tracing = { version = "0.1.40", features = ["log"] }
tracing-actix-web = "0.7.10"
url = { version = "2.5.0", features = ["serde"] }
[dev-dependencies]
actix-rt= "2.9"

30
config/default.toml Normal file
View File

@ -0,0 +1,30 @@
debug = true
source_code = "https://git.batsense.net/ForgeFlux/ForgeFlux"
allow_registration = true
[server]
# Please set a unique value, your mCaptcha instance's security depends on this being
# unique
#cookie_secret = "Zae0OOxf^bOJ#zN^&k7VozgW&QAx%n02TQFXpRMG4cCU0xMzgu3dna@tQ9dvc&TlE6p*n#kXUdLZJCQsuODIV%r$@o4%770ePQB7m#dpV!optk01NpY0@615w5e2Br4d"
# The port at which you want authentication to listen to
# takes a number, choose from 1000-10000 if you dont know what you are doing
port = 7000
#IP address. Enter 0.0.0.0 to listen on all available addresses
ip= "0.0.0.0"
# enter your hostname, eg: example.com
domain = "localhost:7000"
[database]
# This section deals with the database location and how to access it
# Please note that at the moment, we have support for only postgresqa.
# Example, if you are Batman, your config would be:
# url = "postgres://batman:password@batcave.org:5432/batcave"
# database_type = "postgres"
# pool = 4
url = "postgres://example.org" # hack for tests to run successfully
pool = 4
[forges.forgejo]
client_id = "foo"
client_secret = "bar"
url = "http://example.org"

View File

@ -0,0 +1,4 @@
#[async_trait::async_trait]
pub trait CreateDatabase: Send + Sync {
async fn create_database(&self, url: &url::Url);
}

View File

@ -0,0 +1,4 @@
#[async_trait::async_trait]
pub trait DeleteDatabase: Send + Sync {
async fn delete_database(&self, name: &url::Url);
}

4
src/db/migrate.rs Normal file
View File

@ -0,0 +1,4 @@
#[async_trait::async_trait]
pub trait RunMigrations: Send + Sync {
async fn migrate(&self);
}

4
src/db/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod create_database;
pub mod delete_database;
pub mod migrate;
pub mod sqlx_postgres;

43
src/db/sqlx_postgres.rs Normal file
View File

@ -0,0 +1,43 @@
use sqlx::migrate::MigrateDatabase;
use sqlx::postgres::PgPool;
use super::migrate::RunMigrations;
#[derive(Clone)]
pub struct Postgres {
pool: PgPool,
}
impl Postgres {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait::async_trait]
impl RunMigrations for Postgres {
async fn migrate(&self) {
sqlx::migrate!("./migrations/")
.run(&self.pool)
.await
.unwrap();
}
}
pub struct PostgresDatabase;
#[async_trait::async_trait]
impl super::create_database::CreateDatabase for PostgresDatabase {
async fn create_database(&self, url: &url::Url) {
sqlx::Postgres::create_database(url.as_str()).await.unwrap();
}
}
#[async_trait::async_trait]
impl super::delete_database::DeleteDatabase for PostgresDatabase {
async fn delete_database(&self, url: &url::Url) {
sqlx::Postgres::force_drop_database(url.as_str())
.await
.unwrap();
}
}

View File

@ -0,0 +1 @@
pub mod web;

View File

@ -0,0 +1,60 @@
use std::str::FromStr;
use actix_web::{get, http::header, post, web, HttpResponse};
use crate::forge::auth::adapter::out::forge::SupportedForges;
use crate::forge::auth::application::port::input::ui::{
errors::*, login::RequestAuthorizationInterface,
};
use crate::forge::auth::application::services::request_authorization::command::RequestAuthorizationCommand;
use super::{templates::login::LoginCtxFactory, ServiceFactory, WebCtx};
use crate::ActixCtx;
#[async_trait::async_trait]
impl RequestAuthorizationInterface for WebCtx {
#[tracing::instrument(name = "web adapter request_oauth_authorization", skip(self))]
async fn request_oauth_authorization(&self, forge_name: String) -> InUIResult<HttpResponse> {
let service = ServiceFactory::request_authorization(
SupportedForges::from_str(&forge_name).map_err(|_| InUIError::BadRequest)?,
self,
)?;
log::info!("service found");
let cmd = RequestAuthorizationCommand::new_command(forge_name)?;
let auth_page = service.request_authorization(cmd).await?;
Ok(HttpResponse::Found()
.insert_header((header::LOCATION, auth_page.as_str()))
.finish())
}
}
#[get("/login")]
async fn login_page(ctx: ActixCtx) -> InUIResult<HttpResponse> {
let template_ctx =
LoginCtxFactory::get_ctx(ctx.adapters.forges.get_supported_forges(), &ctx.routes);
let page = ctx
.templates
.login_page
.get_login_page(template_ctx)
.unwrap();
Ok(HttpResponse::Ok()
.append_header((header::CONTENT_TYPE, "text/html; charset=UTF-8"))
.body(page))
}
#[post("/oauth/{forge}/login")]
#[tracing::instrument(name = "web handler request_oauth_authorization", skip(ctx))]
async fn request_oauth_authorization(
ctx: ActixCtx,
forge_name: web::Path<String>,
) -> InUIResult<HttpResponse> {
ctx.request_oauth_authorization(forge_name.into_inner())
.await
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.service(login_page);
cfg.service(request_oauth_authorization);
}

View File

@ -0,0 +1,81 @@
use std::sync::Arc;
use actix_web::web::{self, Data};
use url::Url;
use crate::forge::auth::adapter::out::db::DBAdapter;
use crate::forge::auth::adapter::out::forge::{ForgeRepository, SupportedForges};
use crate::forge::auth::application::port::input::ui::errors::{InUIError, InUIResult};
use crate::forge::auth::application::services::request_authorization::service::RequestAuthorizationService;
use crate::forge::auth::application::services::request_authorization::RequestAuthorizationUserCase;
use crate::settings::Settings;
pub mod login;
mod routes;
mod templates;
use routes::RoutesRepository;
pub type ArcCtx = Arc<WebCtx>;
pub type ActixCtx = Data<ArcCtx>;
#[derive(Clone)]
pub struct WebCtx {
pub routes: Arc<RoutesRepository>,
pub adapters: Adapters,
pub templates: templates::Templates,
pub settings: Settings,
}
impl WebCtx {
pub fn new_actix_ctx(forges: ForgeRepository, db: DBAdapter, settings: Settings) -> ActixCtx {
let routes = Arc::new(RoutesRepository::default());
Data::new(Arc::new(Self {
routes: routes.clone(),
adapters: Adapters::new(forges, db),
templates: templates::Templates::default(),
settings,
}))
}
}
#[derive(Clone)]
pub struct Adapters {
pub forges: ForgeRepository,
pub db: DBAdapter,
}
impl Adapters {
pub fn new(forges: ForgeRepository, db: DBAdapter) -> Self {
Self { forges, db }
}
}
pub struct ServiceFactory;
impl ServiceFactory {
pub fn request_authorization(
forge_name: SupportedForges,
ctx: &WebCtx,
) -> InUIResult<Arc<dyn RequestAuthorizationUserCase>> {
if let Some(forge) = ctx.adapters.forges.get_forge(&forge_name) {
Ok(Arc::new(RequestAuthorizationService::new(
ctx.adapters.db.save_oauth_state_adapter.clone(),
forge.get_redirect_uri_adapter.clone(),
Url::parse(&format!(
"{}://{}{}",
"http",
&ctx.settings.server.domain,
&ctx.routes.process_oauth_authorization_response(&forge_name)
))
.map_err(|_| InUIError::InternalServerError)?,
)))
} else {
Err(InUIError::BadRequest)
}
}
}
pub fn services(cfg: &mut web::ServiceConfig) {
cfg.configure(login::services);
}

View File

@ -0,0 +1,28 @@
use crate::forge::auth::adapter::out::forge::SupportedForges;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct RoutesRepository {
process_oauth_authorization_response: String,
pub login: String,
oauth_login: String,
}
impl Default for RoutesRepository {
fn default() -> Self {
Self {
process_oauth_authorization_response: "/oauth/{forge}/authorize".to_string(),
login: "/login".to_string(),
oauth_login: "/oauth/{forge}/login".to_string(),
}
}
}
impl RoutesRepository {
pub fn oauth_login(&self, forge_name: &SupportedForges) -> String {
self.oauth_login.replace("{forge}", &forge_name.to_string())
}
pub fn process_oauth_authorization_response(&self, forge_name: &SupportedForges) -> String {
self.process_oauth_authorization_response
.replace("{forge}", &forge_name.to_string())
}
}

View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
{% for forge in payload.forges -%}
<form action="{{forge.path}}" method="POST">
<button type="submit">
{{forge.name}}
</button>
</form>
{%- endfor %}
</body>
</html>

View File

@ -0,0 +1,60 @@
use serde::{Deserialize, Serialize};
use crate::forge::auth::adapter::input::web::routes::RoutesRepository;
use crate::forge::auth::adapter::out::forge::SupportedForges;
use super::{tera_context, TEMPLATES};
pub trait LoginPageInterface: Send + Sync {
fn get_login_page(&self, ctx: LoginCtx) -> Result<String, Box<dyn std::error::Error>>;
}
use super::TemplateFile;
pub const LOGIN_TEMPLATE: TemplateFile = TemplateFile::new("login", "login.html");
pub fn register_templates(t: &mut tera::Tera) {
LOGIN_TEMPLATE.register(t).expect(LOGIN_TEMPLATE.name);
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Forge {
name: String,
path: String,
}
impl Forge {
pub fn new(name: String, path: String) -> Self {
Self { name, path }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LoginCtx {
pub forges: Vec<Forge>,
}
pub struct LoginCtxFactory;
impl LoginCtxFactory {
pub fn get_ctx(supported_forges: Vec<SupportedForges>, routes: &RoutesRepository) -> LoginCtx {
let mut forges = Vec::with_capacity(supported_forges.len());
for s in supported_forges.iter() {
forges.push(Forge::new(s.to_string(), routes.oauth_login(s)));
}
LoginCtx { forges }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LoginPageTemplate;
impl LoginPageInterface for LoginPageTemplate {
fn get_login_page(&self, ctx: LoginCtx) -> Result<String, Box<dyn std::error::Error>> {
let ctx = tera_context(&ctx);
let page = TEMPLATES
.render(LOGIN_TEMPLATE.name, &ctx.borrow())
.unwrap();
Ok(page)
}
}

View File

@ -0,0 +1,23 @@
use std::sync::Arc;
pub mod login;
pub mod setup;
mod utils;
use login::{LoginPageInterface, LoginPageTemplate};
pub use setup::{TemplateFile, PAYLOAD_KEY, TEMPLATES};
pub use utils::tera_context;
#[derive(Clone)]
pub struct Templates {
pub login_page: Arc<dyn LoginPageInterface>,
}
impl Default for Templates {
fn default() -> Self {
Self {
login_page: Arc::new(LoginPageTemplate),
}
}
}

View File

@ -0,0 +1,62 @@
use lazy_static::lazy_static;
use rust_embed::RustEmbed;
use tera::*;
pub struct TemplateFile {
pub name: &'static str,
pub path: &'static str,
}
impl TemplateFile {
pub const fn new(name: &'static str, path: &'static str) -> Self {
Self { name, path }
}
pub fn register(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
t.add_raw_template(self.name, &Templates::get_template(self).expect(self.name))
}
#[cfg(test)]
#[allow(dead_code)]
pub fn register_from_file(&self, t: &mut Tera) -> std::result::Result<(), tera::Error> {
use std::path::Path;
t.add_template_file(Path::new("templates/").join(self.path), Some(self.name))
}
}
pub const PAYLOAD_KEY: &str = "payload";
//pub const BASE: TemplateFile = TemplateFile::new("base", "components/base.html");
//pub const FOOTER: TemplateFile = TemplateFile::new("footer", "components/footer.html");
//pub const PUB_NAV: TemplateFile = TemplateFile::new("pub_nav", "components/nav/pub.html");
//pub const AUTH_NAV: TemplateFile = TemplateFile::new("auth_nav", "components/nav/auth.html");
lazy_static! {
pub static ref TEMPLATES: Tera = {
let mut tera = Tera::default();
// for t in [BASE, FOOTER, PUB_NAV, AUTH_NAV].iter() {
// t.register(&mut tera).unwrap();
// }
// errors::register_templates(&mut tera);
tera.autoescape_on(vec![".html", ".sql"]);
super::login::register_templates(&mut tera);
// auth::register_templates(&mut tera);
// gists::register_templates(&mut tera);
tera
};
}
#[derive(RustEmbed)]
#[folder = "src/forge/auth/adapter/input/web/templates/"]
#[include = "*.html"]
#[exclude = "*.rs"]
struct Templates;
impl Templates {
fn get_template(t: &TemplateFile) -> Option<String> {
match Self::get(t.path) {
Some(file) => Some(String::from_utf8_lossy(&file.data).into_owned()),
None => None,
}
}
}

View File

@ -0,0 +1,11 @@
use std::cell::RefCell;
use super::PAYLOAD_KEY;
use serde::Serialize;
use tera::Context;
pub fn tera_context<T: Serialize>(s: &T) -> RefCell<Context> {
let c = RefCell::new(Context::new());
c.borrow_mut().insert(PAYLOAD_KEY, s);
c
}

View File

@ -0,0 +1,2 @@
pub mod input;
pub mod out;

View File

@ -0,0 +1,34 @@
use std::sync::Arc;
use sqlx::PgPool;
use url::Url;
use crate::db::migrate::RunMigrations;
use crate::forge::auth::application::port::out::db::save_oauth_state::SaveOAuthState;
pub mod postgres;
#[derive(Clone)]
pub struct DBAdapter {
pub save_oauth_state_adapter: Arc<dyn SaveOAuthState>,
pg_adapter: postgres::DBOutPostgresAdapter,
}
impl DBAdapter {
pub async fn init(database_url: &str) -> Self {
let pool = PgPool::connect(database_url).await.unwrap();
Self::new(pool)
}
fn new(pool: PgPool) -> Self {
let pg_adapter = postgres::DBOutPostgresAdapter::new(pool);
Self {
save_oauth_state_adapter: Arc::new(pg_adapter.clone()),
pg_adapter,
}
}
pub fn migratable(&self) -> Arc<dyn RunMigrations> {
self.pg_adapter.migratable()
}
}

View File

@ -0,0 +1,28 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use sqlx::Error as SqlxError;
use crate::forge::auth::application::port::out::db::errors::OutDBPortError;
impl From<SqlxError> for OutDBPortError {
fn from(value: SqlxError) -> Self {
unimplemented!()
}
}
//impl From<ProcessAuthorizationServiceError> for OutDBPostgresError {
// fn from(v: ProcessAuthorizationServiceError) -> Self {
// match v {
// ProcessAuthorizationServiceError::InteralError => Self::InternalServerError,
// ProcessAuthorizationServiceError::BadRequest => Self::BadRequest,
// }
// }
//}
//
//impl From<RequestAuthorizationServiceError> for OutDBPostgresError {
// fn from(v: RequestAuthorizationServiceError) -> Self {
// match v {
// RequestAuthorizationServiceError::InteralError => Self::InternalServerError,
// }
// }
//}

View File

@ -0,0 +1,24 @@
//use crate::forge::auth::application::port::out::db::save_oauth_state::SaveOAuthState;
use std::sync::Arc;
use sqlx::postgres::PgPool;
use crate::db::{migrate::RunMigrations, sqlx_postgres::Postgres};
mod errors;
mod save_oauth_state;
#[derive(Clone)]
pub struct DBOutPostgresAdapter {
pool: PgPool,
}
impl DBOutPostgresAdapter {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub fn migratable(&self) -> Arc<dyn RunMigrations> {
Arc::new(Postgres::new(self.pool.clone()))
}
}

View File

@ -0,0 +1,52 @@
use url::Url;
use super::DBOutPostgresAdapter;
use crate::forge::auth::application::port::out::db::{
errors::OutDBPortResult, save_oauth_state::SaveOAuthState,
};
#[async_trait::async_trait]
impl SaveOAuthState for DBOutPostgresAdapter {
async fn save_oauth_state(
&self,
state: &str,
oauth_provider: &str,
redirect_uri: &Url,
) -> OutDBPortResult<()> {
sqlx::query!(
"INSERT INTO oauth_state (state, oauth_provider, redirect_uri) VALUES ($1, $2, $3)",
state,
oauth_provider,
redirect_uri.as_str()
)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_rt::test]
async fn test_postgres_save_oauth_state() {
let state = "statetestpostgres";
let oauth_provider = "oauthprovitestpostgres";
let redirect_uri = Url::parse("https://oauthprovitestpostgres").unwrap();
let settings = crate::settings::tests::get_settings().await;
let db = super::DBOutPostgresAdapter::new(
sqlx::postgres::PgPool::connect(&settings.database.url)
.await
.unwrap(),
);
db.save_oauth_state(state, oauth_provider, &redirect_uri)
.await
.unwrap();
settings.drop_db().await;
}
}

View File

@ -0,0 +1,24 @@
use url::Url;
use super::Forgejo;
use crate::forge::auth::application::port::out::forge::{
errors::OutForgePortResult, get_redirect_uri::GetRedirectUri,
};
impl GetRedirectUri for Forgejo {
fn get_redirect_uri(
&self,
state: &str,
process_authorization_response_uri: &Url,
) -> OutForgePortResult<Url> {
let mut u = self.url().to_owned();
u.set_path("/login/oauth/authorize");
u.set_query(Some(&format!(
"client_id={}&redirect_uri={}&response_type=code&state={state}",
self.client_id(),
process_authorization_response_uri.as_str()
)));
Ok(u)
}
}

View File

@ -0,0 +1,32 @@
use url::Url;
mod get_redirect_uri;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Forgejo {
url: Url,
client_id: String,
client_secret: String,
}
impl Forgejo {
pub fn new(url: Url, client_id: String, client_secret: String) -> Self {
Self {
url,
client_id,
client_secret,
}
}
pub fn url(&self) -> &Url {
&self.url
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn client_secret(&self) -> &str {
&self.client_secret
}
}

View File

@ -0,0 +1,89 @@
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
use derive_more::Display;
use serde::{Deserialize, Serialize};
use crate::forge::auth::application::port::out::forge::get_redirect_uri::GetRedirectUri;
use self::forgejo::Forgejo;
pub mod forgejo;
#[derive(Clone)]
pub struct ForgeAdapter {
pub get_redirect_uri_adapter: Arc<dyn GetRedirectUri>,
}
#[derive(Clone, Default)]
pub struct ForgeRepository {
forges: HashMap<SupportedForges, ForgeAdapter>,
}
impl ForgeRepository {
fn add_forge(&mut self, name: SupportedForges, forge_factory: ForgeAdapter) {
self.forges.insert(name, forge_factory);
}
pub fn get_supported_forge_str(&self) -> Vec<String> {
self.forges
.clone()
.into_keys()
.map(|v| v.to_string())
.collect()
}
pub fn get_supported_forges(&self) -> Vec<SupportedForges> {
self.forges.clone().into_keys().collect()
}
pub fn get_forge(&self, name: &SupportedForges) -> Option<&ForgeAdapter> {
self.forges.get(name)
}
pub fn new(forgejo: Forgejo) -> Self {
let forgejo_adapter = ForgeAdapter {
get_redirect_uri_adapter: Arc::new(forgejo),
};
let mut s = Self::default();
s.add_forge(SupportedForges::Forgejo, forgejo_adapter);
s
}
}
#[derive(Debug, Display, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "lowercase")]
pub enum SupportedForges {
#[display(fmt = "forgejo")]
Forgejo,
}
impl FromStr for SupportedForges {
type Err = SupportedForgesError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"forgejo" => Ok(SupportedForges::Forgejo),
_ => Err(SupportedForgesError::UnsupportedForge),
}
}
}
#[derive(Debug, Display, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum SupportedForgesError {
UnsupportedForge,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supported_forges() {
assert_eq!(SupportedForges::Forgejo.to_string(), "forgejo");
assert_eq!(
SupportedForges::from_str("forgejo").unwrap(),
SupportedForges::Forgejo
);
}
}

View File

@ -0,0 +1,2 @@
pub mod db;
pub mod forge;

View File

@ -0,0 +1,51 @@
use actix_web::{
http::{header, StatusCode},
HttpResponse, HttpResponseBuilder, ResponseError,
};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use crate::forge::auth::application::services::{
process_authorization_response::errors::ProcessAuthorizationServiceError,
request_authorization::errors::RequestAuthorizationServiceError,
};
pub type InUIResult<V> = Result<V, InUIError>;
#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum InUIError {
InternalServerError,
BadRequest,
}
impl From<ProcessAuthorizationServiceError> for InUIError {
fn from(v: ProcessAuthorizationServiceError) -> Self {
match v {
ProcessAuthorizationServiceError::InteralError => Self::InternalServerError,
ProcessAuthorizationServiceError::BadRequest => Self::BadRequest,
}
}
}
impl From<RequestAuthorizationServiceError> for InUIError {
fn from(v: RequestAuthorizationServiceError) -> Self {
match v {
RequestAuthorizationServiceError::InteralError => Self::InternalServerError,
}
}
}
impl ResponseError for InUIError {
fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.append_header((header::CONTENT_TYPE, "application/sjon; charset=UTF-8"))
.body(serde_json::to_string(self).unwrap())
}
fn status_code(&self) -> actix_web::http::StatusCode {
match self {
Self::BadRequest => StatusCode::BAD_REQUEST,
Self::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

View File

@ -0,0 +1,7 @@
use super::errors::*;
use actix_web::HttpResponse;
#[async_trait::async_trait]
pub trait RequestAuthorizationInterface: Send + Sync {
async fn request_oauth_authorization(&self, forge_name: String) -> InUIResult<HttpResponse>;
}

View File

@ -0,0 +1,3 @@
pub mod errors;
pub mod login;
// login

View File

@ -0,0 +1,7 @@
use super::errors::*;
#[async_trait::async_trait]
pub trait DeleteOAuthState: Send + Sync {
/// Delete OAuth
async fn delete_oauth_state(&self, state: &str, oauth_provider: &str) -> OutDBPortResult<()>;
}

View File

@ -0,0 +1,9 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
pub type OutDBPortResult<V> = Result<V, OutDBPortError>;
#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum OutDBPortError {
DuplicateState,
}

View File

@ -0,0 +1,7 @@
use super::errors::*;
#[async_trait::async_trait]
pub trait GetOAuthState: Send + Sync {
/// Get OAuth state code generated during authorization request
async fn get_oauth_state(&self, state: &str) -> OutDBPortResult<()>;
}

View File

@ -0,0 +1,47 @@
pub mod delete_oauth_state;
pub mod errors;
pub mod oauth_state_exists;
pub mod save_oauth_state;
#[cfg(test)]
pub mod tests {
use std::sync::{Arc, RwLock};
use url::Url;
use super::*;
use errors::*;
use save_oauth_state::SaveOAuthState;
#[derive(Clone, Default)]
pub struct MockDB {
pub calls: Arc<RwLock<Vec<Call>>>,
}
#[derive(Clone, Default)]
pub struct Call {
pub state: String,
pub oauth_provider: String,
pub redirect_uri: String,
}
#[async_trait::async_trait]
impl SaveOAuthState for MockDB {
async fn save_oauth_state(
&self,
state: &str,
oauth_provider: &str,
redirect_uri: &Url,
) -> OutDBPortResult<()> {
let mut calls = self.calls.write().unwrap();
calls.push(Call {
state: state.to_string(),
oauth_provider: oauth_provider.to_string(),
redirect_uri: redirect_uri.to_string(),
});
Ok(())
}
}
}

View File

@ -0,0 +1,15 @@
use url::Url;
use super::errors::*;
#[async_trait::async_trait]
pub trait OAuthStateExists: Send + Sync {
/// Save OAuth state code generated during authorization request, which will later be used to
/// validate authorization response from OAuth server
async fn oauth_state_exists(
&self,
state: &str,
oauth_provider: &str,
redirect_uri: &Url,
) -> OutDBPortResult<bool>;
}

View File

@ -0,0 +1,15 @@
use url::Url;
use super::errors::*;
#[async_trait::async_trait]
pub trait SaveOAuthState: Send + Sync {
/// Save OAuth state code generated during authorization request, which will later be used to
/// validate authorization response from OAuth server
async fn save_oauth_state(
&self,
state: &str,
oauth_provider: &str,
redirect_uri: &Url,
) -> OutDBPortResult<()>;
}

View File

@ -0,0 +1,9 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
pub type OutForgePortResult<V> = Result<V, OutForgePortError>;
#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum OutForgePortError {
InteralError,
}

View File

@ -0,0 +1,10 @@
use super::errors::*;
use url::Url;
pub trait GetRedirectUri: Send + Sync {
fn get_redirect_uri(
&self,
state: &str,
process_authorization_response_uri: &Url,
) -> OutForgePortResult<Url>;
}

View File

@ -0,0 +1,30 @@
pub mod errors;
pub mod get_redirect_uri;
pub mod refresh_access_token;
pub mod request_access_token;
#[cfg(test)]
pub mod tests {
use url::Url;
use super::*;
use errors::*;
use get_redirect_uri::GetRedirectUri;
#[derive(Clone, Default)]
pub struct MockForge; // {
#[async_trait::async_trait]
impl GetRedirectUri for MockForge {
fn get_redirect_uri(
&self,
state: &str,
process_authorization_response_uri: &Url,
) -> OutForgePortResult<Url> {
let mut u = process_authorization_response_uri.clone();
u.set_query(Some(&format!("state={state}")));
Ok(u)
}
}
}

View File

@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use super::errors::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct RequestAuthorizationCommand {
oauth_provider: String,
}
impl RequestAuthorizationCommand {
pub fn oauth_provider(&self) -> &str {
&self.oauth_provider
}
pub fn new_command(oauth_provider: String) -> RequestAuthorizationServiceResult<Self> {
let oauth_provider = oauth_provider.trim().to_string();
Ok(Self { oauth_provider })
}
}

View File

@ -0,0 +1,28 @@
use derive_more::Display;
use serde::{Deserialize, Serialize};
use crate::forge::auth::application::port::out::db::errors::OutDBPortError;
use crate::forge::auth::application::port::out::forge::errors::OutForgePortError;
pub type RequestAuthorizationServiceResult<V> = Result<V, RequestAuthorizationServiceError>;
#[derive(Debug, Display, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub enum RequestAuthorizationServiceError {
InteralError,
}
impl From<OutDBPortError> for RequestAuthorizationServiceError {
fn from(v: OutDBPortError) -> Self {
match v {
OutDBPortError::DuplicateState => Self::InteralError,
}
}
}
impl From<OutForgePortError> for RequestAuthorizationServiceError {
fn from(v: OutForgePortError) -> Self {
match v {
OutForgePortError::InteralError => Self::InteralError,
}
}
}

View File

@ -0,0 +1,13 @@
use url::Url;
pub mod command;
pub mod errors;
pub mod service;
#[async_trait::async_trait]
pub trait RequestAuthorizationUserCase: Send + Sync {
async fn request_authorization(
&self,
cmd: command::RequestAuthorizationCommand,
) -> errors::RequestAuthorizationServiceResult<Url>;
}

View File

@ -0,0 +1,104 @@
use std::sync::Arc;
use url::Url;
use crate::forge::auth::application::port::out::db::errors::OutDBPortError;
use crate::forge::auth::application::port::out::db::save_oauth_state::SaveOAuthState;
use crate::forge::auth::application::port::out::forge::get_redirect_uri::GetRedirectUri;
use crate::utils;
use super::{errors::*, RequestAuthorizationUserCase};
const STATE_LEN: usize = 8;
#[derive(Clone)]
pub struct RequestAuthorizationService {
save_oauth_state_adapter: Arc<dyn SaveOAuthState>,
get_redirect_uri_adapter: Arc<dyn GetRedirectUri>,
process_authorization_response_redirect_uri: Url,
}
impl RequestAuthorizationService {
pub fn new(
save_oauth_state_adapter: Arc<dyn SaveOAuthState>,
get_redirect_uri_adapter: Arc<dyn GetRedirectUri>,
process_authorization_response_redirect_uri: Url,
) -> Self {
Self {
save_oauth_state_adapter,
get_redirect_uri_adapter,
process_authorization_response_redirect_uri,
}
}
}
#[async_trait::async_trait]
impl RequestAuthorizationUserCase for RequestAuthorizationService {
async fn request_authorization(
&self,
cmd: super::command::RequestAuthorizationCommand,
) -> RequestAuthorizationServiceResult<Url> {
let mut state = utils::get_random(STATE_LEN);
loop {
match self
.save_oauth_state_adapter
.save_oauth_state(
&state,
cmd.oauth_provider(),
&self.process_authorization_response_redirect_uri,
)
.await
{
Err(OutDBPortError::DuplicateState) => {
state = utils::get_random(STATE_LEN);
continue;
}
Ok(_) => break,
Err(e) => return Err(e)?,
}
}
let redirect = self
.get_redirect_uri_adapter
.get_redirect_uri(&state, &self.process_authorization_response_redirect_uri)?;
Ok(redirect)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::forge::auth::application::{
port::out::{db::tests::MockDB, forge::tests::MockForge},
services::request_authorization::command::RequestAuthorizationCommand,
};
use super::*;
#[actix_rt::test]
async fn test_service() {
let save_oauth_state = MockDB::default();
let get_redirect_uri = MockForge::default();
let url = Url::parse("http://test_service_request_auth").unwrap();
let oauth_provider = "test_service_request_auth_oauth_provider";
let s = RequestAuthorizationService::new(
Arc::new(save_oauth_state.clone()),
Arc::new(get_redirect_uri),
url.clone(),
);
let cmd = RequestAuthorizationCommand::new_command(oauth_provider.to_owned()).unwrap();
let res = s.request_authorization(cmd).await.unwrap().to_string();
{
let save_oauth_state_ctx = save_oauth_state.calls.read().unwrap();
let call = save_oauth_state_ctx.get(0).unwrap();
assert_eq!(
res,
format!("http://test_service_request_auth/?state={}", &call.state)
);
assert_eq!(call.oauth_provider, oauth_provider);
assert_eq!(call.redirect_uri, url.to_string());
}
}
}

View File

@ -1,3 +1,56 @@
fn main() {
println!("Hello, world!");
use std::env;
use actix_web::{middleware, App, HttpServer};
mod db;
mod forge;
mod settings;
mod utils;
pub use crate::forge::auth::adapter::input::web::{services, ActixCtx, WebCtx};
pub use crate::forge::auth::adapter::out::{
db::DBAdapter,
forge::{forgejo::Forgejo, ForgeRepository},
};
#[actix_web::main]
async fn main() {
let settings = settings::Settings::new().unwrap();
if env::var("RUST_LOG").is_err() {
env::set_var("RUST_LOG", &settings.log);
}
{
// Settings::new() outputs logs, but since we are only setting up logger _after_ Settings is
// initialized, this dummy reinitialization will output logs.
settings::Settings::new().unwrap();
}
pretty_env_logger::init();
let forgejo = Forgejo::new(
settings.forges.forgejo.url.clone(),
settings.forges.forgejo.client_id.clone(),
settings.forges.forgejo.client_secret.clone(),
);
let forges = ForgeRepository::new(forgejo);
let db = DBAdapter::init(&settings.database.url).await;
db.migratable().migrate().await;
let ctx = WebCtx::new_actix_ctx(forges, db, settings.clone());
let socket_addr = settings.server.get_ip();
HttpServer::new(move || {
App::new()
.wrap(tracing_actix_web::TracingLogger::default())
.wrap(middleware::Compress::default())
.app_data(ctx.clone())
.wrap(
middleware::DefaultHeaders::new().add(("Permissions-Policy", "interest-cohort=()")),
)
.configure(services)
})
.bind(&socket_addr)
.unwrap()
.run()
.await
.unwrap();
}

73
src/settings/database.rs Normal file
View File

@ -0,0 +1,73 @@
use std::env;
use config::{builder::DefaultState, ConfigBuilder, ConfigError};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Deserialize, Serialize, Display, Eq, PartialEq, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum DBType {
#[display(fmt = "postgres")]
Postgres,
}
impl DBType {
fn from_url(url: &Url) -> Result<Self, ConfigError> {
match url.scheme() {
// "mysql" => Ok(Self::Maria),
"postgres" => Ok(Self::Postgres),
_ => Err(ConfigError::Message("Unknown database type".into())),
}
}
}
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
pub struct Database {
pub url: String,
pub pool: u32,
pub database_type: DBType,
}
impl Database {
pub fn env_override(mut s: ConfigBuilder<DefaultState>) -> ConfigBuilder<DefaultState> {
for (parameter, env_var_name) in [
("database.url", "DATABASE_URL"),
("database.pool", "FORGEFLUX_database_POOL"),
]
.iter()
{
if let Ok(val) = env::var(env_var_name) {
log::debug!("Overriding [{parameter}] with environment variable {env_var_name}");
s = s.set_override(parameter, val).unwrap();
}
}
s
}
pub fn set_database_type(&mut self) {
let url =
Url::parse(&self.url).expect("couldn't parse Database URL and detect database type");
self.database_type = DBType::from_url(&url).unwrap();
}
}
#[cfg(test)]
mod tests {
use crate::env_helper;
use super::*;
#[test]
fn test_db_env_override() {
let init_settings = crate::settings::Settings::new().unwrap();
env_helper!(
init_settings,
"DATABASE_URL",
"postgres://test_db_env_override",
database.url
);
env_helper!(init_settings, "FORGEFLUX_database_POOL", 99, database.pool);
}
}

View File

@ -0,0 +1,67 @@
use std::env;
use config::{builder::DefaultState, ConfigBuilder};
use serde::Deserialize;
use url::Url;
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
pub struct Forgejo {
pub url: Url,
pub client_id: String,
pub client_secret: String,
}
impl Forgejo {
pub fn env_override(mut s: ConfigBuilder<DefaultState>) -> ConfigBuilder<DefaultState> {
for (parameter, env_var_name) in [
("forges.forgejo.url", "FORGEFLUX_forges_FORGEJO_url"),
(
"forges.forgejo.client_id",
"FORGEFLUX_forges_FORGEJO_client_id",
),
(
"forges.forgejo.client_secret",
"FORGEFLUX_forges_FORGEJO_client_secret",
),
]
.iter()
{
if let Ok(val) = env::var(env_var_name) {
log::debug!("Overriding [{parameter}] with environment variable {env_var_name}");
s = s.set_override(parameter, val).unwrap();
}
}
s
}
}
#[cfg(test)]
mod tests {
use crate::env_helper;
use super::*;
#[test]
fn test_forges_forgejo_env_override() {
let init_settings = crate::settings::Settings::new().unwrap();
env_helper!(
init_settings,
"FORGEFLUX_forges_FORGEJO_url",
Url::parse("postgres://test_forges_forgejo_env_override").unwrap(),
forges.forgejo.url
);
env_helper!(
init_settings,
"FORGEFLUX_forges_FORGEJO_client_id",
"test_forges_forgejo_env_override_client_id",
forges.forgejo.client_id
);
env_helper!(
init_settings,
"FORGEFLUX_forges_FORGEJO_client_secret",
"test_forges_forgejo_env_override_client_secret",
forges.forgejo.client_secret
);
}
}

View File

@ -0,0 +1,18 @@
use config::{builder::DefaultState, ConfigBuilder};
use serde::Deserialize;
use std::env;
pub mod forgejo;
use forgejo::Forgejo;
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
pub struct Forges {
pub forgejo: Forgejo,
}
impl Forges {
pub fn env_override(mut s: ConfigBuilder<DefaultState>) -> ConfigBuilder<DefaultState> {
Forgejo::env_override(s)
}
}

162
src/settings/mod.rs Normal file
View File

@ -0,0 +1,162 @@
use std::path::Path;
use std::{env, fs};
use config::builder::DefaultState;
use config::{Config, ConfigBuilder, ConfigError, File};
use serde::Deserialize;
use url::Url;
pub mod database;
pub mod forges;
pub mod server;
use database::{DBType, Database};
use forges::Forges;
use server::Server;
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
pub struct Settings {
pub debug: bool,
pub log: String,
pub source_code: String,
pub allow_registration: bool,
pub database: Database,
pub forges: Forges,
pub server: Server,
// pub smtp: Option<Smtp>,
}
impl Settings {
fn env_override(mut s: ConfigBuilder<DefaultState>) -> ConfigBuilder<DefaultState> {
for (parameter, env_var_name) in [
("debug", "FORGEFLUX_debug"),
("source_code", "FORGEFLUX_source_code"),
("allow_registration", "FORGEFLUX_allow_registration"),
]
.iter()
{
if let Ok(val) = env::var(env_var_name) {
log::debug!("Overriding [{parameter}] with environment variable {env_var_name}");
s = s.set_override(parameter, val).unwrap();
}
}
s = Database::env_override(s);
s = Forges::env_override(s);
Server::env_override(s)
}
pub fn new() -> Result<Self, ConfigError> {
let mut s = Config::builder();
const CURRENT_DIR: &str = "./config/default.toml";
const ETC: &str = "/etc/forgeflux/config.toml";
// Will be overridden after config is parsed and loaded into Settings by
// Settings::set_database_type.
// This parameter is not ergonomic for users, but it is required and can be programatically
// inferred. But we need a default value for config lib to parse successfully, since it is
// DBType and not Option<DBType>
s = s
.set_default("database.database_type", DBType::Postgres.to_string())
.expect("unable to set database.database_type default config");
s = s
.set_default("log", "INFO")
.expect("unable to set log default config");
if let Ok(path) = env::var("FORGEFLUX_CONFIG") {
let absolute_path = Path::new(&path).canonicalize().unwrap();
log::info!(
"Loading config file from {}",
absolute_path.to_str().unwrap()
);
s = s.add_source(File::with_name(absolute_path.to_str().unwrap()));
} else if Path::new(CURRENT_DIR).exists() {
let absolute_path = fs::canonicalize(CURRENT_DIR).unwrap();
log::info!(
"Loading config file from {}",
absolute_path.to_str().unwrap()
);
// merging default config from file
s = s.add_source(File::with_name(absolute_path.to_str().unwrap()));
} else if Path::new(ETC).exists() {
log::info!("{}", format!("Loading config file from {}", ETC));
s = s.add_source(File::with_name(ETC));
} else {
log::warn!("Configuration file not found");
}
s = Self::env_override(s);
let mut settings = s.build()?.try_deserialize::<Settings>()?;
settings.check_url();
settings.database.set_database_type();
Ok(settings)
}
fn check_url(&self) {
Url::parse(&self.source_code).expect("Please enter a URL for source_code in settings");
}
}
#[cfg(test)]
pub mod tests {
use url::Url;
use super::Settings;
use crate::db::create_database::CreateDatabase;
use crate::db::delete_database::DeleteDatabase;
use crate::db::migrate::RunMigrations;
use crate::db::sqlx_postgres::Postgres;
use crate::utils::get_random;
#[macro_export]
macro_rules! env_helper {
($init_settings:ident, $env:expr, $val:expr, $val_typed:expr, $($param:ident).+) => {
println!("Setting env var {} to {} for test", $env, $val);
env::set_var($env, $val);
{
let new_settings = crate::settings::Settings::new().unwrap();
assert_eq!(new_settings.$($param).+, $val_typed, "should match");
assert_ne!(new_settings.$($param).+, $init_settings.$($param).+);
}
env::remove_var($env);
};
($init_settings:ident, $env:expr, $val:expr, $($param:ident).+) => {
env_helper!($init_settings, $env, $val.to_string(), $val, $($param).+);
};
}
pub async fn get_settings() -> Settings {
let mut settings = Settings::new().unwrap();
let mut db_url = Url::parse(&settings.database.url).unwrap();
db_url.set_path(&get_random(12));
settings.database.url = db_url.to_string();
crate::db::sqlx_postgres::PostgresDatabase
.create_database(&db_url)
.await;
let db = Postgres::new(
sqlx::postgres::PgPool::connect(&settings.database.url)
.await
.unwrap(),
);
db.migrate().await;
settings
}
impl Settings {
pub async fn drop_db(&self) {
crate::db::sqlx_postgres::PostgresDatabase
.delete_database(&Url::parse(&self.database.url).unwrap())
.await;
}
}
}

54
src/settings/server.rs Normal file
View File

@ -0,0 +1,54 @@
use config::{builder::DefaultState, ConfigBuilder};
use serde::Deserialize;
use std::env;
#[derive(Debug, Clone, Deserialize, Eq, PartialEq)]
pub struct Server {
pub port: u32,
pub domain: String,
pub ip: String,
}
impl Server {
#[cfg(not(tarpaulin_include))]
pub fn get_ip(&self) -> String {
format!("{}:{}", self.ip, self.port)
}
pub fn env_override(mut s: ConfigBuilder<DefaultState>) -> ConfigBuilder<DefaultState> {
for (parameter, env_var_name) in [
("server.port", "PORT"),
("server.domain", "FORGEFLUX_server_DOMAIN"),
("server.ip", "FORGEFLUX_server_IP"),
]
.iter()
{
if let Ok(val) = env::var(env_var_name) {
log::debug!("Overriding [{parameter}] with environment variable {env_var_name}");
s = s.set_override(parameter, val).unwrap();
}
}
s
}
}
#[cfg(test)]
mod tests {
use crate::env_helper;
use super::*;
#[test]
fn test_server_env_override() {
let init_settings = crate::settings::Settings::new().unwrap();
env_helper!(init_settings, "PORT", 22, server.port);
env_helper!(
init_settings,
"FORGEFLUX_server_DOMAIN",
"test_server_env_override.org",
server.domain
);
env_helper!(init_settings, "FORGEFLUX_server_IP", "1.1.1.1", server.ip);
}
}

13
src/utils.rs Normal file
View File

@ -0,0 +1,13 @@
pub fn get_random(len: usize) -> String {
use std::iter;
use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
let mut rng: ThreadRng = thread_rng();
iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(len)
.collect::<String>()
}

1
utils/db-migrations/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1737
utils/db-migrations/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
[package]
name = "db-migrations"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-rt = "2.9.0"
sqlx = { version = "0.7.4", features = ["runtime-tokio-rustls", "postgres", "time"] }

View File

@ -0,0 +1,24 @@
use std::env;
use sqlx::postgres::PgPoolOptions;
#[cfg(not(tarpaulin_include))]
#[actix_rt::main]
async fn main() {
//TODO featuregate sqlite and postgres
postgres_migrate().await;
}
async fn postgres_migrate() {
let db_url = env::var("DATABASE_URL").expect("set DATABASE_URL env var");
let db = PgPoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
.expect("Unable to form database pool");
sqlx::migrate!("../../migrations/")
.run(&db)
.await
.unwrap();
}