use crate::datastructures::{Config, Cookie, FormData, WrapConfigure}; use anyhow::Result; use clap::ArgMatches; use handlebars::Handlebars; use itertools::Itertools as _; use redis::AsyncCommands; use serde::Serialize; use sqlx::sqlite::SqliteConnectOptions; use sqlx::{ConnectOptions, Connection, SqliteConnection}; use std::env; use std::io::{BufRead, Write}; use std::str::FromStr; use tempfile::TempDir; use tokio_stream::StreamExt as _; pub(crate) struct IOModule { reader: R, writer: W, } impl IOModule { pub fn new(reader: R, writer: W) -> Self { Self { reader, writer } } } impl IOModule { // Processing the `authenticate-post` called by cgit. pub(crate) async fn cmd_authenticate_post( &mut self, matches: &ArgMatches, cfg: Config, ) -> Result<()> { // Read stdin from upstream. let mut buffer = String::new(); self.reader.read_to_string(&mut buffer)?; //log::debug!("{}", buffer); let data = FormData::from(buffer); let cfg = WrapConfigure::from(cfg); log::trace!("Method is {}", cfg.get_authorizer().method()); // Establish Redis connection early for rate limiting + session storage let mut conn = crate::datastructures::get_redis_connection(cfg.get_config().redis_url.as_str()) .await?; // Rate limit check let max_attempts = cfg.get_config().max_login_attempts; let login_timeout = cfg.get_config().login_timeout; if max_attempts > 0 { let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); let attempt_count: u64 = conn.get(&rate_limit_key).await.unwrap_or(0); if attempt_count >= max_attempts { log::warn!( "User {} is locked out due to too many failed login attempts", data.get_user() ); writeln!(&mut self.writer, "Status: 403 Forbidden")?; writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; writeln!(&mut self.writer)?; return Ok(()); } } let ret = verify_login(&cfg, &data).await; if let Err(ref e) = ret { eprintln!("{e:?}"); #[cfg(test)] eprintln!( "If database locked error occurs frequently, \ please use environment DISK_WAIT_TIME to specify longer time." ); log::error!("{e:?}") } if ret.unwrap_or(false) { // Clear failed attempt counter on successful login if max_attempts > 0 { let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); let _: () = conn.del(&rate_limit_key).await.unwrap_or(()); } let cookie = Cookie::generate(data.get_user()); conn.set_ex::<_, _, String>( format!("cgit_auth_{}", cookie.get_key()), cookie.get_body(), cfg.get_config().cookie_ttl, ) .await?; let cookie_value = cookie.to_string(); let is_secure = matches .get_one::("https") .map(|s| s.as_str()) .is_some_and(|x| matches!(x, "yes" | "on" | "1")); let domain = matches .get_one::("http-host") .map(|s| s.as_str()) .unwrap_or("*"); let location = matches .get_one::("http-referer") .map(|s| s.as_str()) .unwrap_or("/"); let cookie_suffix = if is_secure { "; Secure" } else { "" }; writeln!(&mut self.writer, "Status: 302 Found")?; writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; writeln!(&mut self.writer, "Location: {location}")?; writeln!( &mut self.writer, "Set-Cookie: cgit_auth={cookie_value}; Domain={domain}; Max-Age={}; HttpOnly; SameSite=Lax{cookie_suffix}", cfg.get_config().cookie_ttl * 10, )?; } else { // Increment rate limit counter on failed login if max_attempts > 0 { let rate_limit_key = format!("cgit_auth_failed_{}", data.get_user()); let new_count: u64 = conn.incr(&rate_limit_key, 1u64).await.unwrap_or(1); if new_count == 1 { let _: () = conn .expire(&rate_limit_key, login_timeout as i64) .await .unwrap_or(()); } log::info!( "Failed login attempt {new_count}/{max_attempts} for user {}", data.get_user() ); } writeln!(&mut self.writer, "Status: 403 Forbidden")?; writeln!(&mut self.writer, "Cache-Control: no-cache, no-store")?; } writeln!(&mut self.writer)?; Ok(()) } } // Processing the `authenticate-cookie` called by cgit. pub(crate) async fn cmd_authenticate_cookie(matches: &ArgMatches, cfg: Config) -> Result { let cookies = matches .get_one::("http-cookie") .map(|s| s.as_str()) .unwrap_or(""); let repo = matches .get_one::("repo") .map(|s| s.as_str()) .unwrap_or(""); /*let current_url = matches.value_of("current-url").unwrap_or("");*/ let mut bypass = false; if cfg.bypass_root /*&& current_url.eq("/")*/ && repo.is_empty() { bypass = true; } if bypass || (!repo.is_empty() && !cfg.check_repo_protect(repo)) { return Ok(true); } if cookies.is_empty() { return Ok(false); } let mut conn = crate::datastructures::get_redis_connection(cfg.redis_url.as_str()).await?; let redis_key = format!("cgit_repo_{repo}"); if !repo.is_empty() && !conn.exists(&redis_key).await? { let sql_conn = SqliteConnectOptions::from_str(cfg.get_database_location())? .read_only(true) .immutable(true) .disable_statement_logging() .connect() .await; if let Err(ref e) = sql_conn { log::error!( "Got error while open sqlite connection: {e:?}\nDatabase location: {}", cfg.get_database_location() ); } let mut sql_conn = sql_conn?; if let Some((users,)) = sqlx::query_as::<_, (String,)>(r#"SELECT "users" FROM "repos" WHERE "repo" = ? "#) .bind(repo) .fetch_optional(&mut sql_conn) .await? { let users = users.split_whitespace().collect::>(); let _: () = conn.sadd(&redis_key, users).await?; } } if let Ok(Some(cookie)) = Cookie::load_from_request(cookies) { log::debug!("Cookie is {cookie:?}"); if let Ok(r) = conn .get::<_, String>(format!("cgit_auth_{}", cookie.get_key())) .await { conn.expire::<_, bool>( format!("cgit_auth_{}", cookie.get_key()), cfg.cookie_ttl as i64, ) .await?; if cookie.eq_body(r.as_str()) { if repo.is_empty() { return Ok(true); } if conn .sismember::<_, _, i32>(&redis_key, cookie.get_user()) .await? == 1 { return Ok(true); } } } log::debug!("{cookie:?}"); } Ok(false) } pub(crate) async fn cmd_init(cfg: Config) -> Result<()> { let loc = std::path::Path::new(cfg.get_database_location()); let exists = loc.exists(); if !exists { std::fs::File::create(loc)?; } let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; if exists { let rows = sqlx::query(r#"SELECT name FROM sqlite_master WHERE type='table' AND name=?"#) .bind("auth_meta") .fetch_all(&mut conn) .await?; if !rows.is_empty() { return Ok(()); } } sqlx::query(crate::database::current::CREATE_TABLES) .execute(&mut conn) .await?; println!("Initialize the database successfully"); drop(conn); cfg.write_database_commit_timestamp().await?; Ok(()) } pub(crate) async fn verify_login(cfg: &WrapConfigure, data: &FormData) -> Result { cfg.hook().await?; data.authorize(cfg.get_authorizer()).await } #[derive(Serialize)] pub struct Meta<'a> { action: &'a str, redirect: &'a str, version: &'a str, } // Processing the `body` called by cgit. pub(crate) async fn cmd_body(matches: &ArgMatches, _cfg: Config) { let source = include_str!("authentication_page.html"); let handlebars = Handlebars::new(); let meta = Meta { action: matches .get_one::("login-url") .map(|s| s.as_str()) .unwrap_or_default(), redirect: matches .get_one::("current-url") .map(|s| s.as_str()) .unwrap_or_default(), version: env!("CARGO_PKG_VERSION"), }; handlebars .render_template_to_write(source, &meta, std::io::stdout()) .unwrap(); } pub(crate) async fn cmd_add_user(matches: &ArgMatches, cfg: Config) -> Result<()> { let re = regex::Regex::new(r"^[a-zA-Z0-9_][a-zA-Z0-9_.\-]*[a-zA-Z0-9_]$").unwrap(); let user = matches .get_one::("user") .map(|s| s.as_str()) .unwrap_or_default(); let passwd = matches .get_one::("password") .map(|s| s.to_string()) .unwrap_or_default(); if user.is_empty() || passwd.is_empty() { return Err(anyhow::Error::msg("Invalid user or password length")); } if user.len() >= 20 { return Err(anyhow::Error::msg("Username length should less than 21")); } if !re.is_match(user) { return Err(anyhow::Error::msg( "Username must be at least 2 characters, contain only alphanumeric characters, underscores, dots, or hyphens, and must not start or end with a dot or hyphen", )); } let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; let items = sqlx::query(r#"SELECT 1 FROM "accounts" WHERE "user" = ? "#) .bind(user) .fetch_all(&mut conn) .await?; if !items.is_empty() { return Err(anyhow::Error::msg("User already exists!")); } let uid = uuid::Uuid::new_v4().to_string(); sqlx::query(r#"INSERT INTO "accounts" VALUES (?, ?, ?) "#) .bind(user) .bind(FormData::gen_string_argon2_hash(&passwd)?) .bind(&uid) .execute(&mut conn) .await?; println!("Insert {user} ({uid}) to database"); drop(conn); cfg.write_database_commit_timestamp().await?; Ok(()) } pub(crate) async fn cmd_list_user(cfg: Config) -> Result<()> { let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; let (count,) = sqlx::query_as::<_, (i32,)>(r#"SELECT COUNT(*) FROM "accounts""#) .fetch_one(&mut conn) .await?; if count > 0 { let mut iter = sqlx::query_as::<_, (String,)>(r#"SELECT "user" FROM "accounts""#).fetch(&mut conn); println!( "There is {count} user{} in database", if count > 1 { "s" } else { "" } ); while let Some(Ok((row,))) = iter.next().await { println!("{row}") } } else { println!("There is not user exists.") } Ok(()) } pub(crate) async fn cmd_delete_user(matches: &ArgMatches, cfg: Config) -> Result<()> { let user = matches .get_one::("user") .map(|s| s.as_str()) .unwrap_or(""); if user.is_empty() { return Err(anyhow::Error::msg("Please input a valid username")); } let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; let items = sqlx::query_as::<_, (i32,)>(r#"SELECT 1 FROM "accounts" WHERE "user" = ?"#) .bind(user) .fetch_all(&mut conn) .await?; if items.is_empty() { return Err(anyhow::Error::msg(format!("User {user} not found"))); } sqlx::query(r#"DELETE FROM "accounts" WHERE "user" = ?"#) .bind(user) .execute(&mut conn) .await?; println!("Delete {user} from database"); cfg.write_database_commit_timestamp().await?; Ok(()) } pub(crate) async fn cmd_reset_database(matches: &ArgMatches, cfg: Config) -> Result<()> { if !matches.contains_id("confirm") { return Err(anyhow::anyhow!( "Please add --confirm argument to process reset", )); } let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; sqlx::query(crate::database::current::DROP_TABLES) .execute(&mut conn) .await?; sqlx::query(crate::database::current::CREATE_TABLES) .execute(&mut conn) .await?; println!("Reset database successfully"); cfg.write_database_commit_timestamp().await?; Ok(()) } pub(crate) async fn cmd_upgrade_database(cfg: Config) -> Result<()> { let tmp_dir = TempDir::new()?; let v2_path = tmp_dir.path().join("v2.db"); let v3_path = tmp_dir.path().join("v3.db"); drop(std::fs::File::create(&v3_path).expect("Create v3 database failure")); std::fs::copy(cfg.get_database_location(), &v2_path) .expect("Copy v2 database to tempdir failure"); let mut origin_conn = SqliteConnectOptions::from_str(v2_path.as_path().to_str().unwrap())? .read_only(true) .immutable(true) .connect() .await?; let (v,) = sqlx::query_as::<_, (String,)>( r#"SELECT "value" FROM "auth_meta" WHERE "key" = 'version' "#, ) .fetch_optional(&mut origin_conn) .await? .unwrap(); #[allow(deprecated)] if v.eq(crate::database::previous::VERSION) { let mut conn = SqliteConnection::connect(v3_path.as_path().to_str().unwrap()).await?; sqlx::query(crate::database::current::CREATE_TABLES) .execute(&mut conn) .await?; let mut iter = sqlx::query_as::<_, (String, String, String)>(r#"SELECT * FROM "accounts""#) .fetch(&mut origin_conn); while let Some(Ok((user, passwd, uid))) = iter.next().await { sqlx::query(r#"INSERT INTO "accounts" VALUES (?, ?, ?)"#) .bind(user.as_str()) .bind(passwd) .bind(uid.as_str()) .execute(&mut conn) .await?; log::debug!("Process user: {user} ({uid})"); } drop(conn); std::fs::copy(&v3_path, cfg.get_database_location()) .expect("Copy back to database location failure"); println!("Upgrade database successful"); } else { eprintln!( "Got database version {v} but {} required", crate::database::previous::VERSION ) } drop(origin_conn); tmp_dir.close()?; cfg.write_database_commit_timestamp().await?; Ok(()) } pub(crate) async fn cmd_repo_user_control( matches: &ArgMatches, cfg: Config, is_delete: bool, ) -> Result<()> { let repo = matches .get_one::("repo") .map(|s| s.as_str()) .unwrap_or(""); let user = matches .get_one::("user") .map(|s| s.as_str()) .unwrap_or_default(); let clear_all = is_delete && matches.contains_id("clear-all"); if repo.is_empty() || (is_delete && !clear_all && user.is_empty()) || (!is_delete && user.is_empty()) { return Err(anyhow::Error::msg("Invalid repository or username")); } let mut redis_conn = crate::datastructures::get_redis_connection(cfg.redis_url.as_str()).await?; let mut conn = SqliteConnection::connect(cfg.get_database_location()).await?; if sqlx::query(r#"SELECT "users" FROM "repos" WHERE "repo" = ?"#) .bind(repo) .fetch_optional(&mut conn) .await? .is_none() { if is_delete { println!("Row is empty."); return Ok(()); } sqlx::query(r#"INSERT INTO "repos" VALUES (?, '')"#) .bind(repo) .execute(&mut conn) .await?; } let (users,) = sqlx::query_as::<_, (String,)>(r#"SELECT "users" FROM "repos" WHERE "repo" = ?"#) .bind(repo) .fetch_optional(&mut conn) .await? .unwrap(); let mut users = users.split_whitespace().collect::>(); if let Some(index) = users.clone().into_iter().position(|x| x.eq(user)) { if is_delete { if clear_all { users.clear(); } else { users.remove(index); } } else { return Err(anyhow::Error::msg("User already in repository ACL")); } } if !is_delete { users.push(user); } sqlx::query(r#"UPDATE "repos" SET "users" = ? WHERE "repo" = ?"#) .bind(users.join(" ")) .bind(repo) .execute(&mut conn) .await?; let redis_key = format!("cgit_repo_{repo}"); if redis_conn.exists::<_, i32>(&redis_key).await? == 0 { redis_conn.sadd::<_, _, i32>(&redis_key, users).await?; } else if is_delete { if clear_all { redis_conn.del::<_, i32>(&redis_key).await?; } else { redis_conn.srem::<_, _, i32>(&redis_key, user).await?; } } else { redis_conn.sadd::<_, _, i32>(&redis_key, user).await?; } if !clear_all { println!( "{} user {user} {} repository {repo} ACL successful", if is_delete { "Delete" } else { "Add" }, if is_delete { "from" } else { "to" }, ); } else { println!("Clear all users from repository {repo} ACL"); } Ok(()) } pub(crate) async fn cmd_list_repos_acl(arg_matches: &ArgMatches, cfg: Config) -> Result<()> { let repo = arg_matches .get_one::("repo") .map(|s| s.as_str()) .unwrap_or(""); let mut conn = SqliteConnectOptions::from_str(cfg.get_database_location())? .read_only(true) .immutable(true) .connect() .await?; if repo.is_empty() { let (length,) = sqlx::query_as::<_, (i32,)>(r#"SELECT COUNT(*) FROM "repos""#) .fetch_optional(&mut conn) .await? .unwrap_or((0,)); println!( "There is total {length} {} in database", if length == 1 { "repository" } else { "repositories" }, ); let mut iter = sqlx::query_as::<_, (String, String)>(r#"SELECT * FROM "repos""#).fetch(&mut conn); while let Some(Ok((repo, users))) = iter.next().await { println!("{repo}: {}", users.split_whitespace().join(",")) } } else { let ret = sqlx::query_as::<_, (String, String)>(r#"SELECT * FROM "repos" WHERE "repo" = ?"#) .bind(repo) .fetch_optional(&mut conn) .await?; if let Some((repo, users)) = ret { println!("{repo}: {}", users.split_whitespace().join(",")) } else { println!("Repository {repo} not register in database") } } Ok(()) }