/* * actions * * router fns, basically */ use axum::{ extract::{Path, Query}, response::{AppendHeaders, Html, IntoResponse, Redirect}, Form, }; use axum_extra::{headers::Cookie, TypedHeader}; //use colored::Colorize; use git2::{Repository, RepositoryInitOptions}; use html_escape::encode_text; use http::header::CONTENT_TYPE; use lazy_static::lazy_static; use pwhash::bcrypt; use regex::Regex; use serde::Deserialize; use tokio::time::Duration; use crate::{ com::{self, basename, trim_left}, db::{self, db_err_fmt, Database, DbErr, Settings, User}, macros::*, now, CFG, }; use std::{ collections::HashMap, fmt::Debug, fs::{self, DirEntry, File}, io::{self, Read}, process::Command, time::SystemTime, }; /** unwrap a cookie to get a user * * ``` * $c: Option>, * $d: Database * ``` */ //#[macro_export] macro_rules! whoami { ( $c:expr, $d:expr ) => {{ if let Some(ref c) = $c { let x: Option<&str> = c.get("s"); if let Some(s) = x { match $d.get_session(s) { Ok(s) => match $d.get_user_by_id(s.uid) { Ok(x) => Ok(x), Err(ref e) => { err_fmt!("error getting user: {}", db_err_fmt(e)) } }, Err(e) => err_fmt!("error getting session: {e}"), } } else { err_fmt!("failed to retrieve session from cookie") } } else { err_fmt!("failed to unwrap cookie") } }}; } /** generate a table from the repositories owned by a user $u * * ``` * $u: User * $d: Database * ``` */ macro_rules! mkrepotable { ( $u:expr, $d:expr) => {{ let n = &$u.name; match $d.get_user_repos($u.id) { Ok(x) if x.len() > 0 => x .iter() .map(|y| { format!( r#" {u}/{n} {d} "#, u = format!("~{}", encode_text(&n)), n = encode_text(&y.name), d = encode_text(&y.desc) ) }) .collect::(), /* the list is empty */ Ok(_) => String::from("no repos yet!"), Err(ref e) => { let f = db_err_fmt(e); format!("error getting repos: {f}") } } }}; } macro_rules! mkpinned { ( $u:expr, $d:expr) => {{ match $d.get_pinned_repos($u.id) { Ok(x) if x.len() > 0 => x .iter() .map(|y| { format!( r#" {u}/{n} {d} "#, u = format!("~{}", encode_text(&$u.name)), n = encode_text(&y.name), d = encode_text(&y.desc) ) }) .collect::(), Ok(_) => String::new(), Err(ref e) => format!("error getting repos: {e:?}"), } }}; } /** log the page when it's loaded */ macro_rules! log_page { ($x:expr) => {{ log!("{} {}", "loaded page".yellow().bold(), $x); }}; } type HtmlResult = Result>; /** home page */ pub async fn index(c: Option>) -> HtmlResult> { log_page!("index"); match Database::new("run/git.db") { Ok(db) => { let stranger = page_fmt! { ("home"), ( r#"

badboy git

lightweight git hosting for badboy.institute .

you aren't logged in. do you want to register or log in?

name
password

name
password

repos on this server

{rt}
"#, rt = { match db.get_users() { Ok(v) => v .into_iter() .map(|u| mkrepotable!(u, db)) .collect::>() .join("\n"), Err(DbErr::UserNotFound(e)) => { elog!("failed getting user: {e}"); String::new() } Err(DbErr::QueryFailed(e)) => { elog!("failed getting user: {e}"); String::new() } } } ) }; /* display dashboard if we're logged in, * otherwise login forms */ match whoami!(c, db) { Ok(u) => { let n = u.name.clone(); let r = mkrepotable!(u, db); Ok(page_fmt! { ("home :: {}", encode_text(&n)), ( r#"

hello, {u}

new repository

name
description

repositories

{r}
"#, u = encode_text(&format!("~{n}")) ) }) } Err(_) => Ok(stranger), } } Err(e) => err_fmt_html!("failed to open database: {e}"), } } /** view a user's profile */ pub async fn view_user( Path(n): Path, c: Option>, ) -> HtmlResult> { log_page!("view_user"); match Database::new("run/git.db") { Ok(db) => match db.get_user(&n) { Ok(u) => Ok(page_fmt! { ("{}", &n), ( r#"

~{n}

repositories

{pin}

pinned repos

{p}

all repos

{t}
"#, pin = match whoami!(c, db) { Ok(x) => if &u.name == &x.name { fmt!( r#"

pin a repository

"#, u = x.id ) } else { String::new() }, Err(_) => String::new() }, p = mkpinned!(u, db), t = mkrepotable!(u, db) ) }), Err(DbErr::UserNotFound(e)) => { err_fmt_html!("error getting user: {e}") } Err(DbErr::QueryFailed(e)) => { err_fmt_html!("error getting user: {e}") } }, Err(e) => err_fmt_html!("failed to open database: {e}"), } } /** get the path of a file in a repo. * n: username, r: repo name, p: path * * returns: Result<(path (String), Database, Repo, User)> */ macro_rules! get_repo_file { ($n:expr, $r:expr, $p:expr) => {{ match Database::new("run/git.db") { Ok(db) => { match db.get_user($n) { Ok(u) => { /* repo path */ let rp = mkrepopath!(&u.name, $r); /* repo struct */ let rs = match db.get_repo(u.id, $r) { Ok(x) => x, Err(e) => { let r = repo_name($n, $r); return err_fmt_into!( "repo {r} not found: {e}" ); } }; Ok((format!("{rp}.full/{}", $p), db, rs, u)) } Err(ref e) => { let f = db_err_fmt(e); err_fmt!("failed to get user: {f}") } } } Err(e) => err_fmt!("failed to open database: {e}"), } }}; } /** get the path of a repo * n: username, r: repo name * * returns: Result<(path (String), Database, Repo, User)> */ macro_rules! get_repo { ($n:expr, $r:expr) => {{ match Database::new("run/git.db") { Ok(db) => { match db.get_user($n) { Ok(u) => { /* repo path */ let rp = mkrepopath!(&u.name, $r); /* repo struct */ let rs = match db.get_repo(u.id, $r) { Ok(x) => x, Err(e) => { let r = repo_name($n, $r); return { let f = format!("repo {r} not found: {e}"); Err(f.into()) }; } }; Ok((format!("{rp}.full"), db, rs, u)) } Err(ref e) => { let f = db_err_fmt(e); err_fmt!("error getting user: {f}") } } } Err(e) => err_fmt!("failed to open database: {e}"), } }}; } /** view a repo's index */ pub async fn view_src_index( Path((n, r)): Path<(String, String)>, ) -> HtmlResult> { log_page!("view_src_index"); /* just redirect to view the base dir */ view_src(Path((n, r, "".to_string()))).await } lazy_static! { /** regex to extract paths in view_src. * global var just so we don't have to use a bunch of space in the fn */ static ref PATH_REGEX: String = format!( "/home/(?{s})/(?{s})\\.full/(?

[a-zA-Z0-9\\.\\/-]*)$", /* match a string part of a path */ s = "[a-zA-Z0-9\\.-_]+" ); } /** format a repo name */ fn repo_name(u: T, r: T) -> String where String: From, { let (u, r) = (String::from(u), String::from(r)); format!("~{u}/{r}") } /** format a repo name into html with links */ fn repo_name_html(u: T, r: T) -> String where String: From, { let (u, r) = (format!("~{}", encode_text(str_from!(u))), String::from(r)); format!( r#"{u}/{r}"#, r = encode_text(&r), u = encode_text(&u), ) } /** view source of a repo */ pub async fn view_src( Path((n, r, p)): Path<(String, String, String)>, ) -> HtmlResult> { log_page!("view_src"); match get_repo_file!(&n, &r, &p) { Ok((f, _db, _rs, _u)) => { /* username with the tilde */ let u = format!("~{}", encode_text(&n)); /* split the path and add the links to each file */ let ps = p.split("/").map(|x| String::from(x)).scan(vec![], |x, y| { x.push(y); Some(x.clone()) }); /* the path encoded with html */ let hp = ps .map(|x| { let mut v = Vec::new(); for i in (0..x.len()).into_iter() { /* create the link for each file * in the path */ let a = (&x[0..i + 1]) .into_iter() .map(|_| { format!( r#"{f}"#, r = encode_text(&r), f = x[x.len() - 1], p = (&x[0..i + 1]).join("/") ) }) .collect::>(); v.push(a[a.len() - 1].clone()); } v[v.len() - 1].clone() }) .collect::>() .join("/"); /* title */ let t = fmt!("{}/{}", repo_name(&n, &r), encode_text(&p)); /* encodede html title */ let ht = format!("{}/{hp}", repo_name_html(&n, &r)); let path = std::path::Path::new(&f); if path.is_file() { /* link to raw file */ let raw = format!( "/~{}/{}/raw/{}", encode_text(&n), encode_text(&r), encode_text(&p) ); match fs::read_to_string(f) { Ok(c) => Ok(page_fmt! { ("{t} :: view file"), ( r#"

{ht}

view raw


{c}
"#, c = encode_text(&c), ) }), Err(e) => err_fmt_html!("failed to read file: {e}"), } } else if path.is_dir() { log!("displaying dir ~{n}/{r}/{p}: {f}"); /* dir */ let d = match fs::read_dir(&f) { Ok(x) => x, Err(e) => { return err_fmt_html!("failed to read dir: {e}"); } }; /* file list */ let fl = d /* git rid of .git */ .filter(|x| match x { Ok(x) => match x.path().as_os_str().to_str() { Some(x) => basename(x) != ".git", None => false, }, Err(e) => { elog!("error getting file: {e}"); false } }) /* get the file */ .map(|x| match display_file(&n, &r, x) { Ok(x) => x, Err(e) => { format!("

file err: {}

", encode_text(&e)) } }) .collect::>() .join("\n"); Ok(page_fmt! { ("{t} :: view directory"), (r#" {ht}
{fl}
"#) }) } else { err_fmt_html!("~{n}/{r}/{p} is an unsupported file type") } } Err(e) => err_fmt_html!("failed to get file: {e}"), } } /** redirect via headers for typing reasons */ fn redirect(x: T) -> impl IntoResponse where String: From, { let x = String::from(x); log!("redirecting to {x}"); Redirect::to(&x) } /** format a redirect */ macro_rules! redirect_fmt { ($($t:tt)*) => {{ redirect(format!($($t)*)) }}; } /** display a file, for use in view_src */ fn display_file( n: &str, r: &str, x: io::Result, ) -> Result { match x { Ok(x) => { /* shadowing because fuck you */ let p = x.path(); let p = p.as_os_str().to_str(); let p = match p { Some(x) => x, None => "", }; /* file path */ let q = match Regex::new(&PATH_REGEX) { Ok(r) => { let c = match r.captures(p) { Some(x) => x, None => { return err_fmt!("failed to capture regex for {p}") } }; String::from(&c["p"]) } Err(e) => return err_fmt!("failed to mk regex: {e}"), }; /* file */ let f = trim_left(basename(p), '/'); /* href */ let h = com::trim_right( format!("/{}/src/{}", repo_name(n, r), encode_text(&q)), '/', ); /* link () */ let a = format!( r#"{f}"#, f = encode_text(&f), h = encode_text(&h) ); ok_fmt!("{a}") } Err(e) => err_fmt!("{e}"), } } /** view a raw file */ pub async fn view_raw( Path((n, r, p)): Path<(String, String, String)>, ) -> HtmlResult { log_page!("view_raw"); match get_repo_file!(&n, &r, &p) { Ok((f, _, _, _)) => { let path = std::path::Path::new(&f); Ok(if path.is_file() { redirect_fmt!("/~{}/{}/show/{}", &n, &r, &p) } else { redirect_fmt!("/~{}/{}/src/{}", &n, &r, &p) }) } Err(e) => { err_fmt_html!("failed to read file {n}/{r}/{p}: {e}") } } } /** show a file as raw text * this is its own function separate from view_raw for typing reasons * view_raw has to return a redirect, so we just send a form and display the * file that way */ pub async fn show_raw( Path((n, r, p)): Path<(String, String, String)>, c: Option>, ) -> HtmlResult { log_page!("show_raw"); match get_repo_file!(&n, &r, &p) { Ok((f, mut db, rs, u)) => { let path = std::path::Path::new(&f); if path.is_file() { dbg_log!("displaying raw file: {f}"); /* repo name */ let rn = repo_name(&n, &r); /* error for if it's private */ let prv = err_fmt_html!("{rn} is private"); /* the file content */ let fc = match fs::read_to_string(f) { Ok(c) => c, Err(e) => return err_fmt_html!("error reading file: {e}"), }; /* the redirect */ let to = Ok(( AppendHeaders([(CONTENT_TYPE, "text/plain".to_string())]), fc, )); /* settings */ let s = match db.get_settings(rs.id) { Ok(s) => s, Err(e) => { elog!("failed to get settings for {rn}: {e}"); Settings::new(rs.id) } }; if com::i64_to_bool(s.private) { let me = match whoami!(c, db) { Ok(x) => x, Err(e) => { return err_fmt_html!("err getting login: {e}") } }; if me.name == u.name { log!("{rn}: displaying private repo"); to } else { prv } } else { log!("{rn}: repo isn't private"); to } } else { err_fmt_html!("user {n} not found") } } Err(e) => err_fmt_html!("error reading file {n}/{r}/{p}: {e}"), } } /** the admin panel */ pub async fn admin( Path((n, r)): Path<(String, String)>, c: Option>, ) -> HtmlResult> { log_page!("admin"); match get_repo!(&n, &r) { Ok((_f, mut db, rs, u)) => { /* settings, is default */ let (s, _is_def) = match db.get_settings(rs.id) { Ok(x) => (x, false), Err(e) => { elog!( "{}: error getting settings: {e}", "error getting repo".red().bold() ); (Settings::new(rs.id), true) } }; match whoami!(c, db) { Ok(me) => { if &me.name == &u.name { /* repo name */ let rn = format!("~{}/{}", encode_text(&n), encode_text(&r)); Ok(page_fmt! { ("admin panel :: {rn}"), ( r#"

admin panel :: {rn}

settings

private? {p:?}
"#, /* is private? */ prv = if s.private == 0 { "" } else { r#"checked="checked""# }, p = { let r = s.private; log!( "private?: r: {r:?}: to bool: {}", com::i64_to_bool(r) ); com::i64_to_bool(r) }, r = encode_text(&r), ) }) } else { err_fmt_html!("you don't own this repository") } } Err(e) => { err_fmt_html!("failed to get user info: {e}") } } } Err(e) => err_fmt_html!("error getting repo: {e}"), } } /** do we have permission to view a repo? */ fn has_permission( c: &Option>, db: &mut Database, u: &User, i: i64, ) -> Result { match db.repo_is_private(i) { Ok(true) => match whoami!(c, db) { Ok(me) => Ok(me.id == u.id), Err(_e) => Ok(false), }, Ok(false) => Ok(true), Err(e) => err_fmt!("failed to query db: {e}") } } /** view repo page */ pub async fn view_repo( Path((n, r)): Path<(String, String)>, c: Option>, ) -> HtmlResult> { log_page!("view_repo"); match Database::new("run/git.db") { Ok(mut db) => { match (&mut db).get_user(&n) { Ok(u) => { let _me = match whoami!(c, db) { Ok(x) => Some(x), Err(e) => { elog!("whoami! returned err: {e}"); None } }; /* repo struct */ let rs = match db.get_repo(u.id, &r) { Ok(x) => x, Err(e) => { return err_fmt_html!("failed to get repo: {e}") } }; match has_permission(&c, &mut db, &u, rs.id) { Ok(x) if !x => { return err_fmt_html!("permission denied") } Ok(_) => (), Err(e) => { return err_fmt_html!("failed to check perms: {e}") } } /* repo path */ let rp = mkrepopath!(n, r); /* the repo itself */ let repo = match Repository::open_bare(rp) { Ok(x) => x, Err(e) => { return err_fmt_html!("failed to open repo: {e}") } }; /* repo name */ let rn = format!( r#"~{n}/{r}"#, n = encode_text(&n), r = encode_text(&r) ); /* readme */ let f = format!("{}.full/README", mkrepopath!(n, r)); let rm = match File::open(f.clone()) { Ok(mut f) => { let mut s = String::new(); match f.read_to_string(&mut s) { Ok(_) => s, Err(e) => { dbg_elog!("error reading {f:?}: {e}"); String::new() } } } Err(e) => { dbg_elog!("error opening {f}: {e}"); String::new() } }; /* read the config * it's ok to unwrap because we know the config would * have already caused a crash */ let addr = &CFG["git_addr"].as_str().unwrap(); let port = &CFG["git_port"].as_str().unwrap(); /* branches */ let b = match repo.branches(None) { Ok(x) => x .map(|x| match x { Ok(x) => match x.0.name() { Ok(n) => { if let Some(n) = n { encode_fmt!("{}", &n) } else { "branch name is None".to_string() } } Err(e) => { fmt!("branch name err: {e}") } }, Err(e) => { fmt!("failed to get branch: {e}") } }) .collect::>() .join("
"), Err(e) => format!("no repos found: {e}"), }; log!("&r: {r}"); Ok(page_fmt! { ("{}", repo_name(&n, &r)), ( r#"

{rn}

{rd}

cloning

read only git://{a}/~{n}/{r}
read/write ssh://{n}@{a}:{p}/~{n}/{r}

view source

{admin}


{rm}

branches

{b}

"#, rd = encode_text(&rs.desc), rm = encode_text(&rm), n = encode_text(&n), r = encode_text(&r), a = addr, p = port, /* admin panel */ admin = match whoami!(c, db) { Ok(me) => if &me.name == &u.name { format!( r#" admin panel "#, repo_name(&n, &r) ) } else { String::new() }, Err(_e) => { String::new() } } ) }) } Err(DbErr::UserNotFound(e)) => { err_fmt_html!("user not found: {e}") } Err(DbErr::QueryFailed(e)) => { err_fmt_html!("failed to query database: {e}") } } } Err(e) => err_fmt_html!("failed to open database: {e}"), } } /** display an error */ pub async fn err(Query(q): Query>) -> Html { log_page!("err"); let m = match q.get("m") { Some(x) => x, None => "", }; let goto = match q.get("goto") { Some(x) => x, None => "/", }; page_fmt! { ("error :: {m}"), ( r#"

{m}

go back.

"# ) } } #[derive(Deserialize, Debug)] pub struct UserForm { pub name: String, pub pass: String, } /** duh */ pub async fn register( Form(f): Form ) -> HtmlResult { log_page!("register"); let db = match Database::new("run/git.db") { Ok(x) => x, Err(e) => return err_fmt_html!("failed getting database: {e}"), }; let n = &f.name; match db.add_user(n, &f.pass) { Ok(_) => Ok(redirect_fmt!("/")), Err(e) => err_fmt_html!("error adding user: {e}"), } } pub async fn login(Form(f): Form) -> HtmlResult { log_page!("login"); if let Ok(db) = Database::new("run/git.db") { let n = &f.name; let u = match db.get_user(n) { Ok(x) => x, Err(ref e) => { let f = db_err_fmt(e); return err_fmt_html!("{f}"); } }; log!("logging in user: {:?}", u); if bcrypt::verify(f.pass, &u.hash) { let h = match db.add_session(u.id) { Ok(x) => String::from(x), Err(e) => { return err_fmt_html!("failed to add session: {e}"); } }; dbg_log!("created hash: {h}"); Ok(( AppendHeaders([( "Set-Cookie", format!( "s={}; SameSite=Strict; Expires={}; Max-Age=172800; HttpOnly", h, httpdate::fmt_http_date(SystemTime::now() + Duration::from_secs(172800)) ), )]), Redirect::to("/"), )) } else { err_fmt_html!("incorrect password") } } else { err_fmt_html!("failed to open database") } } #[derive(Deserialize, Debug)] pub struct SettingsForm { pub repo: String, pub private: Option, } /** modify a repo's settings */ pub async fn settings( c: Option>, Form(f): Form, ) -> HtmlResult { log_page!("settings"); match Database::new("run/git.db") { Ok(mut db) => { let me = match whoami!(c, db) { Ok(x) => x, Err(e) => return err_fmt_html!("error getting login: {e}"), }; /* repo struct */ let rs = match db.get_repo(me.id, &f.repo) { Ok(x) => x, Err(e) => { return err_fmt_html!("failed to get repo from db: {e}") } }; /* old settings */ let _s = match db.get_settings(rs.settings) { Ok(x) => x, Err(_) => db.new_settings(rs.id)?, }; /* make the new settings */ let s = Settings { id: _s.id, private: com::bool_to_i64(if let Some(p) = f.private { log!("p: {p}"); if p == "checked" || p == "on" { true } else { return err_fmt_html!( "private value is not checked or on: {p}" ); } } else { false }), oid: rs.id, }; match db.set_settings(s) { Ok(_) => Ok(redirect_fmt!("/~{}/{}/admin", &me.name, &rs.name)), Err(e) => err_fmt_html!("failed to set settings: {e}"), } } Err(e) => err_fmt_html!("failed to open database: {e}"), } } /** actually run the code to make the repo */ fn mkrepo(n: &T, d: &T, u: &db::User) -> HtmlResult where String: From, T: Clone, { /* lol! */ let (n, d) = (String::from(n.clone()), String::from(d.clone())); let r = format!("/home/{}/{n}", u.name); /* copy hooks */ for x in ["post-update", "post-receive"].iter() { let f = format!("etc/{x}.sample"); let p = format!("{r}/hooks/{x}"); match fs::copy(&f, &p) { Ok(_) => log!("successfully copied hooks"), Err(e) => { elog!("failed to copy git hooks!!: {e}"); return err_fmt_html!( r#" somehow, we were unable to copy the files required to create your repository. delete it and try again.
error message: {e} "# ); } }; match Command::new("chmod").arg("a+x").arg(&p).output() { Ok(_) => (), Err(e) => { elog!("failed to chmod {p}"); return err_fmt_html!( r#" we were unable to change ownership of your repository files. delete this repository and create it again.
error message: {e} "# ); } }; } /* should we just pass the db as an arg? */ let db = match Database::new("run/git.db") { Ok(x) => x, Err(e) => return err_fmt_html!("failed to open database: {e}"), }; match db.add_repo(&n, &d, u.id) { Ok(_) => { match Command::new("etc/chown-repo.sh") .arg(&r) .arg(&u.name) .output() { Ok(_) => Ok(Redirect::to("/")), Err(e) => { err_fmt_html!("failed to own repository: {e}") } } } Err(e) => err_fmt_html!("failed to add repo: {e}"), } } #[derive(Deserialize, Debug)] pub struct Pin { name: String, uid: i64, } /** pin a repository to your profile */ pub async fn pin( c: Option>, Form(f): Form, ) -> HtmlResult { log_page!("pin"); match Database::new("run/git.db") { Ok(db) => match whoami!(c, db) { Ok(me) => { if me.id == f.uid { match db.get_repo(f.uid, &f.name) { Ok(r) => match db.add_pin(me.id, r.id) { Ok(_) => Ok(redirect_fmt!("/~{}", me.name)), Err(e) => { err_fmt_html!("failed to add pin: {e}") } }, Err(e) => err_fmt_html!("repo not found: {e}"), } } else { err_fmt_html!("you don't own this repository") } } Err(e) => err_fmt_html!("not logged in: {e}"), }, Err(e) => err_fmt_html!("failed to open database: {e}"), } } #[derive(Deserialize, Debug)] pub struct Create { name: String, desc: String, } /** create a new repo; post request */ pub async fn create( c: Option>, Form(f): Form, ) -> HtmlResult { log_page!("create"); if let Some(c) = c { if let Some(s) = c.get("s") { if let Ok(db) = Database::new("run/git.db") { match db.get_session(s) { Ok(sesh) => { let uid = sesh.uid; match db.get_user_by_id(uid) { Ok(u) => { if !db.repo_exists(u.id, &f.name) { let n = &f.name; let d = &f.desc; let p = &mkrepopath!(&u.name, n); match Repository::init_opts( p, RepositoryInitOptions::new() .bare(true) .mkdir(true) .no_reinit(true) .description(d), ) { Ok(_) => { let n = n.to_string(); let d = d.to_string(); mkrepo(&n, &d, &u) } Err(e) => { err_fmt_html!( "failed to git init: {e}" ) } } } else { err_fmt_html!("repo already exists") } } Err(DbErr::UserNotFound(e)) => { err_fmt_html!("{e}") } Err(DbErr::QueryFailed(e)) => { err_fmt_html!("{e}") } } } Err(e) => err_fmt_html!("failed to fetch session: {e}"), } } else { err_fmt_html!("failed to open database") } } else { err_fmt_html!("not logged in") } } else { err_fmt_html!("not logged in") } }