feat: add commit, push handler

- add new feature commit changes and push to remote

Signed-off-by: Pakin <pakin.t@forth.co.th>
This commit is contained in:
Pakin 2026-01-22 17:20:03 +07:00
parent 2dd165b451
commit 3043f30012
4 changed files with 667 additions and 231 deletions

View file

@ -1,197 +1,546 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use axum::{extract::{Query, State}, response::IntoResponse, routing::get, Json, Router};
use git2::{Repository, RemoteCallbacks, FetchOptions, Cred};
use log::{error, warn};
use libtbr::recipe_functions::common;
use serde::Deserialize;
use serde_json::{json, Value};
use axum::{
Json, Router,
extract::{Query, State},
response::IntoResponse,
routing::{get, post},
};
use axum_macros::debug_handler;
use git2::{Cred, FetchOptions, PushOptions, RemoteCallbacks, Repository};
use libtbr::{models, recipe_functions::common};
use log::{error, info, warn};
use redis::TypedCommands;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::sync::Mutex;
use crate::{gcm, reg};
#[derive(Clone)]
pub struct AppState {
cached_country_names: Vec<&'static str>,
configures: gcm::Configure
// cached_country_names: Vec<&'static str>,
configures: gcm::Configure,
repo: Arc<Mutex<Repository>>,
redis: Arc<Mutex<redis::Client>>,
}
impl AppState {
pub fn check_country_existed(&self, req: &str) -> bool {
self.cached_country_names.contains(&req)
}
// pub fn check_country_existed(&self, req: &str) -> bool {
// self.cached_country_names.contains(&req)
// }
pub fn get_config(&self, config_name: &str) -> Option<&String> {
self.configures.get(config_name)
}
pub fn get_config(&self, config_name: &str) -> Option<&String> {
self.configures.get(config_name)
}
pub fn get_all_configures(self) -> gcm::Configure {
self.configures.clone()
}
}
#[derive(Deserialize)]
struct CheckoutParams {
path: String
path: String,
}
async fn checkout_handler(State(state): State<AppState>, Query(param): Query<CheckoutParams>) -> impl IntoResponse {
let mut response: HashMap<String, Value> = HashMap::new();
async fn checkout_handler(
State(state): State<AppState>,
Query(param): Query<CheckoutParams>,
) -> impl IntoResponse {
let mut response: HashMap<String, Value> = HashMap::new();
let repo_path = state.get_config("GIT_REPO_LOCAL_DEST");
if repo_path.is_none() {
response.insert("error".to_string(), json!("config repo dest not found".to_string()));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
);
}
let legit_path = param.path.as_str();
// match param.path.as_str() {
// legit_path if param.path.contains("/") || state.check_country_existed(param.path.as_str()) || param.path.is_empty() => {
// }
// _ => {
// let error_log = "requested path is unexpected";
// error!("{error_log}");
// response.insert("error".to_string(), json!(error_log));
// }
// }
let rpath = repo_path.unwrap().clone();
let repo = match Repository::open_bare(rpath) {
Ok(repo) => repo,
Err(_) => {
let error_log = "unable to open repo as bare";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
);
let repo_path = state.get_config("GIT_REPO_LOCAL_DEST");
if repo_path.is_none() {
response.insert(
"error".to_string(),
json!("config repo dest not found".to_string()),
);
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
let fpath = format!("master:{}", legit_path);
let obj = match repo.revparse_single(&fpath){
Ok(obj) => obj,
Err(e) => {
let error_log = format!("unexpected revparse single: {err}", err = e.message());
error!("{error_log}");
response.insert("error".to_string(), json!(error_log.clone()));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
);
}
};
let legit_path = param.path.as_str();
if let Some(blob) = obj.as_blob() {
let content = unsafe {
str::from_utf8_unchecked(blob.content())
// match param.path.as_str() {
// legit_path if param.path.contains("/") || state.check_country_existed(param.path.as_str()) || param.path.is_empty() => {
// }
// _ => {
// let error_log = "requested path is unexpected";
// error!("{error_log}");
// response.insert("error".to_string(), json!(error_log));
// }
// }
let rpath = repo_path.unwrap().clone();
let repo = match Repository::open_bare(rpath) {
Ok(repo) => repo,
Err(_) => {
let error_log = "unable to open repo as bare";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
response.insert("result".to_string(), json!(content.to_string()));
} else if let Some(tree) = obj.as_tree() {
let dir_list = tree.iter().map(|x| x.name().unwrap_or("").to_string()).collect::<Vec<String>>();
response.insert("result".to_string(), json!(dir_list));
} else {
let error_log = "not obj nor tree";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::BAD_REQUEST,
Json(json!(response))
)
}
(
axum::http::StatusCode::OK,
Json(json!(response))
)
let fpath = format!("master:{}", legit_path);
let obj = match repo.revparse_single(&fpath) {
Ok(obj) => obj,
Err(e) => {
let error_log = format!("unexpected revparse single: {err}", err = e.message());
error!("{error_log}");
response.insert("error".to_string(), json!(error_log.clone()));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
if let Some(blob) = obj.as_blob() {
let content = unsafe { str::from_utf8_unchecked(blob.content()) };
response.insert("result".to_string(), json!(content.to_string()));
} else if let Some(tree) = obj.as_tree() {
let dir_list = tree
.iter()
.map(|x| x.name().unwrap_or("").to_string())
.collect::<Vec<String>>();
response.insert("result".to_string(), json!(dir_list));
} else {
let error_log = "not obj nor tree";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (axum::http::StatusCode::BAD_REQUEST, Json(json!(response)));
}
(axum::http::StatusCode::OK, Json(json!(response)))
}
async fn fetch_handler(State(state): State<AppState>) -> impl IntoResponse {
let mut response: HashMap<String, Value> = HashMap::new();
if let Some(repo_path) = state.get_config("GIT_REPO_LOCAL_DEST") {
let rpath = repo_path.clone();
let mut response: HashMap<String, Value> = HashMap::new();
if let Some(repo_path) = state.get_config("GIT_REPO_LOCAL_DEST") {
let rpath = repo_path.clone();
let repo = match Repository::open_bare(rpath) {
Ok(repo) => repo,
Err(_) => {
let error_log = "unable to open bare repo";
let repo = match Repository::open_bare(rpath) {
Ok(repo) => repo,
Err(_) => {
let error_log = "unable to open bare repo";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
let mut remote = match repo.find_remote("origin") {
Ok(remote) => remote,
Err(e) => {
let error_log = format!("unable to find remote, {}", e.message());
error!("{error_log}");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
if state.get_config("GIT_REPO_USERNAME").is_none()
|| state.get_config("GIT_REPO_PASSWORD").is_none()
{
warn!("username or password not provided may cause fetching fail");
}
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_, _, _| {
Cred::userpass_plaintext(
state
.get_config("GIT_REPO_USERNAME")
.unwrap_or(&"".to_string()),
state
.get_config("GIT_REPO_PASSWORD")
.unwrap_or(&"".to_string()),
)
});
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
match remote.fetch(&["origin"], Some(&mut fetch_options), None) {
Ok(_) => {}
Err(e) => {
error!("error while fetching {}", e.message());
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("error while fetching {}", e.message())})),
);
}
}
} else {
let error_log = "cannot find local repo";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response)),
);
}
};
let mut remote = match repo.find_remote("origin") {
Ok(remote) => remote,
Err(e) => {
let error_log = format!("unable to find remote, {}", e.message());
error!("{error_log}");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
);
}
};
if state.get_config("GIT_REPO_USERNAME").is_none() || state.get_config("GIT_REPO_PASSWORD").is_none() {
warn!("username or password not provided may cause fetching fail");
}
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(|_, _, _| {
Cred::userpass_plaintext(
state.get_config("GIT_REPO_USERNAME").unwrap_or(&"".to_string()),
state.get_config("GIT_REPO_PASSWORD").unwrap_or(&"".to_string())
)
});
(
axum::http::StatusCode::OK,
Json(json!({"result": "fetch success"})),
)
}
let mut fetch_options = FetchOptions::new();
fetch_options.remote_callbacks(callbacks);
// { path: "/path/to/file", signature: {
// username: "", email: ""
// }, patch_key: "edit_id_from_redis"}
#[derive(Deserialize)]
struct CommitBody {
// Actual file path
path: String,
// Signature of user
signature: Signature,
// Key to grep content changes from redis
patch_key: String,
// user message
message: Option<String>,
#[serde(flatten)]
extra: HashMap<String, Value>,
}
match remote.fetch(&["origin"], Some(&mut fetch_options), None) {
Ok(_) => {}
Err(e) => {
error!("error while fetching {}", e.message());
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": format!("error while fetching {}", e.message())}))
);
}
#[derive(Deserialize)]
struct Signature {
username: String,
email: String,
}
#[debug_handler]
async fn commit_handler(
State(state): State<AppState>,
// request body
Json(payload): Json<CommitBody>,
) -> impl IntoResponse {
let mut content = match fetch_content_from_redis(state.redis.clone(), &payload.patch_key).await
{
Ok(c) => c,
Err(e) => {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(json!({"error": e})),
);
}
};
let is_patch_file = content.starts_with("patch");
// do apply patch first
if is_patch_file {
content = apply_patch_to_file(state.redis.clone(), &payload.path, &mut content).await;
}
} else {
let error_log = "cannot find local repo";
error!("{error_log}");
response.insert("error".to_string(), json!(error_log));
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!(response))
);
}
(
axum::http::StatusCode::OK,
Json(json!({"result": "fetch success"}))
)
let commit_oid = match commit_file_content(
state.repo,
&payload.path,
&content.as_bytes(),
payload.signature,
&payload.message.unwrap_or("update: from api".to_string()),
)
.await
{
Ok(oid) => oid,
Err(e) => {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
);
}
};
// save history
let redis_pre_lock = state.redis.clone();
{
if let Ok(mut rl) = redis_pre_lock.try_lock() {
let _ = rl.rpush(format!("{}.history", payload.path), payload.patch_key);
}
}
(
axum::http::StatusCode::OK,
Json(json!({"result": format!("{commit_oid}")})),
)
}
async fn fetch_content_from_redis(
redis: Arc<Mutex<redis::Client>>,
key: &str,
) -> Result<String, String> {
match redis.lock().await.get(key) {
Ok(s) => {
if let Some(res) = s {
Ok(res)
} else {
Err(format!("result error from key: {key}"))
}
}
Err(e) => Err(format!("redis get failed: {e}")),
}
}
fn read_patch_from_str(patch_changes: &str) -> Option<Value> {
//
let pv = patch_changes.replace("patch ", "");
let change_map: Value = match serde_json::from_str(&pv) {
Ok(s) => s,
Err(_) => return None,
};
Some(change_map)
}
fn get_product_code(v: &Value) -> Option<&str> {
v.get("productCode")?.as_str()
}
fn diff_apply(target: &mut Value, patch: &Value) -> Result<(), String> {
match (target, patch) {
(Value::Object(target_map), Value::Object(patch_map)) => {
for (k, v_patch) in patch_map {
let v_target = target_map
.get_mut(k)
.ok_or_else(|| format!("Unknown key in patch: {k}"))?;
diff_apply(v_target, v_patch)?;
}
Ok(())
}
(Value::Array(target_arr), Value::Array(patch_arr)) => {
for patch_elem in patch_arr {
// NOTE: support only `Recipe01`
let patch_id = get_product_code(patch_elem)
.ok_or("Patch array element missing product code")?;
let target_elem = target_arr
.iter_mut()
.find(|e| get_product_code(e) == Some(patch_id))
.ok_or_else(|| format!("Unknown id in patch array: {patch_id}"))?;
diff_apply(target_elem, patch_elem)?;
}
Ok(())
}
(target_slot, patch_value) => {
*target_slot = patch_value.clone();
Ok(())
}
}
}
async fn apply_patch_to_file(
redis: Arc<Mutex<redis::Client>>,
path: &str,
patch_changes: &mut String,
) -> String {
use libtbr::*;
// expect the path to already has in redis
let full_file = match fetch_content_from_redis(redis, path).await {
Ok(f) => f,
Err(_) => String::new(),
};
if full_file.is_empty() {
return full_file;
}
// read into struct
//
let full_recipe: models::recipe::Recipe = match serde_json::from_str(&full_file) {
Ok(f) => f,
Err(_) => return String::new(),
};
// read patch
let patch_map = read_patch_from_str(patch_changes);
let mut current_full_map = match serde_json::to_value(full_recipe.clone()) {
Ok(m) => m,
Err(_) => return String::new(),
};
if let Some(pm) = patch_map {
match diff_apply(&mut current_full_map, &pm) {
Ok(_) => {}
Err(x) => {
error!("error while applied patch: {x}");
}
}
}
match serde_json::to_string(&current_full_map.clone()) {
Ok(ss) => ss,
Err(_) => String::new(),
}
}
async fn commit_file_content(
repo: Arc<Mutex<Repository>>,
path: &str,
content: &[u8],
author: Signature,
message: &str,
) -> Result<git2::Oid, Box<dyn std::error::Error>> {
let repo_clone = repo.clone();
let blob_oid = repo_clone.lock().await.blob(content)?;
info!("blob oid: {blob_oid}");
let mut index = repo_clone.lock().await.index()?;
info!("index pass");
let rlock = repo_clone.try_lock()?;
let parent = match rlock.head() {
Ok(head) => {
let commit = head.peel_to_commit()?;
index.read_tree(&commit.tree()?)?;
Some(commit.clone())
}
Err(_) => None,
};
info!("parent pass");
index.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
file_size: content.len() as u32,
id: blob_oid,
flags: 0,
flags_extended: 0,
path: path.as_bytes().to_vec(),
})?;
info!("index added");
let tree_oid = index.write_tree()?;
info!("write to tree");
// let tlock = repo_clone.try_lock()?;
info!("acquire try lock");
let tree = rlock.find_tree(tree_oid)?;
info!("find tree ok");
let sig = git2::Signature::now(&author.username, &author.email)?;
info!("generated signature");
let parents: Vec<&git2::Commit> = parent.iter().collect();
let oid = rlock.commit(
Some("refs/heads/master"),
&sig,
&sig,
message,
&tree,
&parents,
)?;
info!("commit oid: {oid}");
Ok(oid)
}
async fn push_handler(State(state): State<AppState>) -> impl IntoResponse {
let config = state.clone().get_all_configures();
let repo = state.repo.clone();
let remote_name = match state.get_config("GIT_REPO_REMOTE") {
Some(s) => s,
None => {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "repo remote not existed"})),
);
}
};
let branch = "master";
if let Err(e) = push(config, repo, remote_name, branch) {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": e.to_string()})),
);
}
(
axum::http::StatusCode::OK,
Json(json!({"result": "push completed"})),
)
}
fn push(
config: gcm::Configure,
repo: Arc<Mutex<Repository>>,
remote_name: &str,
branch: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if let Ok(rlock) = repo.try_lock() {
let mut rem = rlock.find_remote(remote_name)?;
let mut callback = RemoteCallbacks::new();
callback.credentials(|_url, _user, _allowed| {
Cred::userpass_plaintext(
config.get("GIT_REPO_USERNAME").unwrap_or(&"".to_string()),
config.get("GIT_REPO_PASSWORD").unwrap_or(&"".to_string()),
)
});
let mut push_opts = PushOptions::new();
push_opts.remote_callbacks(callback);
let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
rem.push(&[&refspec], Some(&mut push_opts))?;
return Ok(());
}
Err("cannot lock repo".into())
}
pub async fn run(config: gcm::Configure) -> gcm::StandardResult {
let state = AppState {
cached_country_names: common::valid_country_name(),
configures: config.clone()
};
let state = AppState {
// cached_country_names: common::valid_country_name(),
configures: config.clone(),
repo: Arc::new(Mutex::new(Repository::open_bare(
config.get("GIT_REPO_LOCAL_DEST").unwrap_or(&"".to_string()),
)?)),
redis: Arc::new(Mutex::new(redis::Client::open(format!(
"redis://{}:{}",
config.get("REDIS_URI").unwrap_or(&"".to_string()),
config.get("REDIS_PORT").unwrap_or(&"".to_string())
))?)),
};
let app = Router::new()
.route("/checkout", get(checkout_handler))
.route("/fetch", get(fetch_handler))
.route("/healthz", get(reg::health))
.with_state(state);
let app = Router::new()
.route("/checkout", get(checkout_handler))
.route("/fetch", get(fetch_handler))
.route("/commit", post(commit_handler))
.route("/push", get(push_handler))
.route("/healthz", get(reg::health))
.with_state(state);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.clone().get("PUBLIC_PORT").unwrap_or(&"36583".to_string()))).await?;
let listener = tokio::net::TcpListener::bind(format!(
"0.0.0.0:{}",
config
.clone()
.get("PUBLIC_PORT")
.unwrap_or(&"36583".to_string())
))
.await?;
axum::serve(listener, app).await?;
axum::serve(listener, app).await?;
Ok(())
}
Ok(())
}