use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::json;
use crate::{
error::AppError, git, hooks::HookJob, routes::AuthorRequest, state::AppState,
util::run_blocking, validate,
};
#[derive(Deserialize)]
pub struct ListFilesQuery {
pub prefix_path: Option<String>,
pub maximum_depth: Option<u32>,
pub page: Option<usize>,
pub per_page: Option<usize>,
}
const DEFAULT_PER_PAGE: usize = 100;
const MAX_PER_PAGE: usize = 500;
#[derive(Deserialize)]
pub struct WriteFileRequest {
pub author: AuthorRequest,
pub content: String,
pub message: Option<String>,
}
#[derive(Deserialize)]
pub struct DeleteFileRequest {
pub author: AuthorRequest,
pub message: Option<String>,
}
#[derive(Deserialize)]
pub struct MoveFileRequest {
pub author: AuthorRequest,
pub destination: String,
pub message: Option<String>,
}
pub async fn list_files(
State(state): State<AppState>,
Path((collection_id, tenant_id)): Path<(String, String)>,
Query(query): Query<ListFilesQuery>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let path_prefix: Option<String> = query
.prefix_path
.as_deref()
.map(validate::folder_path)
.transpose()?
.filter(|p| !p.is_empty())
.map(|p| p.to_string());
let maximum_depth: Option<usize> = match query.maximum_depth {
Some(0) => {
return Err(AppError::InvalidOperation {
reason: "maximum_depth must be at least 1".to_string(),
})
}
Some(d) => Some(d as usize),
None => None,
};
let page = query.page.unwrap_or(1).max(1);
let per_page = query
.per_page
.unwrap_or(DEFAULT_PER_PAGE)
.clamp(1, MAX_PER_PAGE);
tracing::debug!(collection_id = %collection_id, tenant_id = %tenant_id, path_prefix = ?path_prefix, maximum_depth = ?maximum_depth, page = page, per_page = per_page, "handling list files request");
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
let tenant_id_for_task = tenant_id.clone();
let (tree, has_more) = run_blocking(move || {
git::GitFiles::list_files(
&repo_path,
&tenant_id_for_task,
path_prefix.as_deref(),
maximum_depth,
page,
per_page,
)
})
.await?;
tracing::debug!(tenant_id = %tenant_id, page = page, returned = tree.len(), has_more = has_more, "list files tree response ready");
Ok(Json(json!({
"page": page,
"per_page": per_page,
"has_more": has_more,
"files": tree,
})))
}
pub async fn read_file(
State(state): State<AppState>,
Path((collection_id, tenant_id, file_path)): Path<(String, String, String)>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let file_path = validate::file_path(&file_path)?.to_string();
tracing::debug!(collection_id = %collection_id, tenant_id = %tenant_id, path = %file_path, "handling read file request");
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
let file_path_for_task = file_path.clone();
let tenant_id_for_task = tenant_id.clone();
let content = run_blocking(move || {
git::GitFiles::read_file(&repo_path, &tenant_id_for_task, &file_path_for_task)
})
.await?;
Ok(Json(json!({
"path": file_path,
"content": content,
})))
}
pub async fn file_exists(
State(state): State<AppState>,
Path((collection_id, tenant_id, file_path)): Path<(String, String, String)>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let file_path = validate::file_path(&file_path)?.to_string();
tracing::debug!(collection_id = %collection_id, tenant_id = %tenant_id, path = %file_path, "handling file existence request");
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
run_blocking(move || git::GitFiles::file_exists(&repo_path, &tenant_id, &file_path)).await?;
Ok(StatusCode::OK)
}
pub async fn write_file(
State(state): State<AppState>,
Path((collection_id, tenant_id, file_path)): Path<(String, String, String)>,
Json(body): Json<WriteFileRequest>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let file_path = validate::file_path(&file_path)?.to_string();
validate::file_extension(
&file_path,
state.config.server.allowed_extensions.as_deref(),
)?;
tracing::debug!(collection_id = %collection_id, tenant_id = %tenant_id, path = %file_path, "handling write file request");
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
let lock_key = format!("{}/{}", collection_id, tenant_id);
let lock = state.get_repo_lock(&lock_key);
let _lock_guard = lock.lock().await;
let WriteFileRequest {
author,
content,
message,
} = body;
let repo_path_for_maintenance = repo_path.clone();
let (commit_sha, file_change) = run_blocking(move || {
git::GitFiles::write_file(
&repo_path,
&file_path,
&content,
message.as_deref(),
&author.name,
&author.email,
)
})
.await?;
tracing::debug!(tenant_id = %tenant_id, sha = %commit_sha, "file write committed, enqueuing hook delivery");
state.hook_queue.enqueue(
&lock_key,
HookJob {
tenant_id,
commit_sha: commit_sha.clone(),
committed_at: Utc::now(),
file_changes: vec![file_change],
},
);
state
.maintenance
.schedule(&lock_key, repo_path_for_maintenance, lock.clone());
Ok((StatusCode::OK, Json(json!({ "commit_sha": commit_sha }))))
}
pub async fn delete_file(
State(state): State<AppState>,
Path((collection_id, tenant_id, file_path)): Path<(String, String, String)>,
Json(body): Json<DeleteFileRequest>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let file_path = validate::file_path(&file_path)?.to_string();
tracing::debug!(collection_id = %collection_id, tenant_id = %tenant_id, path = %file_path, "handling delete file request");
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
let lock_key = format!("{}/{}", collection_id, tenant_id);
let lock = state.get_repo_lock(&lock_key);
let _lock_guard = lock.lock().await;
let DeleteFileRequest { author, message } = body;
let repo_path_for_maintenance = repo_path.clone();
let tenant_id_for_task = tenant_id.clone();
let (commit_sha, file_change) = run_blocking(move || {
git::GitFiles::delete_file(
&repo_path,
&tenant_id_for_task,
&file_path,
message.as_deref(),
&author.name,
&author.email,
)
})
.await?;
tracing::debug!(tenant_id = %tenant_id, sha = %commit_sha, "file deletion committed, enqueuing hook delivery");
state.hook_queue.enqueue(
&lock_key,
HookJob {
tenant_id,
commit_sha: commit_sha.clone(),
committed_at: Utc::now(),
file_changes: vec![file_change],
},
);
state
.maintenance
.schedule(&lock_key, repo_path_for_maintenance, lock.clone());
Ok((StatusCode::OK, Json(json!({ "commit_sha": commit_sha }))))
}
pub async fn move_file(
State(state): State<AppState>,
Path((collection_id, tenant_id, raw_path)): Path<(String, String, String)>,
Json(body): Json<MoveFileRequest>,
) -> Result<impl IntoResponse, AppError> {
let collection_id = validate::collection_id(&collection_id)?.to_string();
let tenant_id = validate::tenant_id(&tenant_id)?.to_string();
let from_path_raw = raw_path
.strip_suffix("/move")
.ok_or_else(|| AppError::InvalidPath {
reason: "POST on a file path must end with /move".to_string(),
})?;
let from_path = validate::file_path(from_path_raw)?.to_string();
let to_path = validate::file_path(&body.destination)?.to_string();
validate::file_extension(&to_path, state.config.server.allowed_extensions.as_deref())?;
tracing::debug!(
collection_id = %collection_id,
tenant_id = %tenant_id,
from_path = %from_path,
to_path = %to_path,
"handling move file request"
);
let repo_path = state
.config
.server
.repos_path
.join(&collection_id)
.join(&tenant_id);
let lock_key = format!("{}/{}", collection_id, tenant_id);
let lock = state.get_repo_lock(&lock_key);
let _lock_guard = lock.lock().await;
let MoveFileRequest {
author,
destination: _,
message,
} = body;
let repo_path_for_maintenance = repo_path.clone();
let tenant_id_for_task = tenant_id.clone();
let (commit_sha, file_change) = run_blocking(move || {
git::GitFiles::move_file(
&repo_path,
&tenant_id_for_task,
&from_path,
&to_path,
message.as_deref(),
&author.name,
&author.email,
)
})
.await?;
tracing::debug!(tenant_id = %tenant_id, sha = %commit_sha, "file move committed, enqueuing hook delivery");
state.hook_queue.enqueue(
&lock_key,
HookJob {
tenant_id,
commit_sha: commit_sha.clone(),
committed_at: Utc::now(),
file_changes: vec![file_change],
},
);
state
.maintenance
.schedule(&lock_key, repo_path_for_maintenance, lock.clone());
Ok((StatusCode::OK, Json(json!({ "commit_sha": commit_sha }))))
}