use axum::{Form, extract::Query, response::Html};
use serde::Deserialize;
use std::path::PathBuf;
use crate::pitchfork_toml::PitchforkToml;
use crate::web::bp;
use crate::web::helpers::html_escape;
fn base_html(title: &str, content: &str) -> String {
let bp = bp();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} - pitchfork</title>
<link rel="icon" type="image/x-icon" href="{bp}/static/favicon.ico">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="{bp}/static/style.css">
</head>
<body>
<nav>
<a href="{bp}/" class="nav-brand"><img src="{bp}/static/logo.png" alt="pitchfork" class="logo-icon"> pitchfork</a>
<div class="nav-links">
<a href="{bp}/">Dashboard</a>
<a href="{bp}/logs">Logs</a>
<a href="{bp}/config" class="active">Config</a>
</div>
</nav>
<main>
{content}
</main>
<script>
// Initialize Lucide icons on page load
lucide.createIcons();
// Re-initialize Lucide icons after HTMX swaps content
document.body.addEventListener('htmx:afterSwap', function(evt) {{
lucide.createIcons();
}});
</script>
</body>
</html>"#
)
}
fn get_allowed_paths() -> Vec<PathBuf> {
PitchforkToml::list_paths()
}
fn safe_canonicalize(path: &PathBuf) -> Option<PathBuf> {
if let Ok(canonical) = std::fs::canonicalize(path) {
return Some(canonical);
}
let mut existing_ancestor = path.clone();
let mut non_existing_parts: Vec<std::ffi::OsString> = Vec::new();
while !existing_ancestor.exists() {
if let Some(file_name) = existing_ancestor.file_name() {
non_existing_parts.push(file_name.to_os_string());
} else {
return None;
}
existing_ancestor = existing_ancestor.parent()?.to_path_buf();
}
let canonical_base = std::fs::canonicalize(&existing_ancestor).ok()?;
let mut result = canonical_base;
for part in non_existing_parts.into_iter().rev() {
result = result.join(part);
}
Some(result)
}
fn validate_path(path: &PathBuf) -> Option<PathBuf> {
let allowed = get_allowed_paths();
let canonical_input = safe_canonicalize(path)?;
let is_allowed = allowed.iter().any(|allowed_path| {
safe_canonicalize(allowed_path)
.map(|canonical_allowed| canonical_allowed == canonical_input)
.unwrap_or(false)
});
if is_allowed {
Some(canonical_input)
} else {
None
}
}
pub async fn list() -> Html<String> {
let bp = bp();
let paths = get_allowed_paths();
let mut file_list = String::new();
for path in paths {
let exists = path.exists();
let display = html_escape(&path.display().to_string());
let status_class = if exists { "exists" } else { "not-created" };
let status_text = if exists { "EXISTS" } else { "NOT CREATED" };
let path_str = path.to_string_lossy();
let encoded_path = urlencoding::encode(&path_str);
file_list.push_str(&format!(
r#"
<div class="config-card {status_class}">
<div class="config-path">{display}</div>
<div class="config-status">
<span class="status-badge {status_class}">{status_text}</span>
<a href="{bp}/config/edit?path={encoded_path}" class="btn btn-sm"><i data-lucide="edit" class="icon"></i> Edit</a>
</div>
</div>
"#
));
}
let content = format!(
r#"
<div class="page-header">
<div>
<h1>Configuration Files</h1>
<p class="subtitle">Pitchfork loads configuration from these locations (in order of precedence)</p>
</div>
</div>
<div class="config-list">
{file_list}
</div>
<div class="help-box">
<strong>💡 Note:</strong> Later files override earlier ones. Click Edit to modify a configuration file.
</div>
"#
);
Html(base_html("Configuration", &content))
}
#[derive(Deserialize)]
pub struct EditQuery {
path: String,
}
pub async fn edit(Query(query): Query<EditQuery>) -> Html<String> {
let bp = bp();
let path = PathBuf::from(&query.path);
let canonical_path = match validate_path(&path) {
Some(p) => p,
None => {
let content = format!(
r#"
<h1>Error</h1>
<p class="error">This file path is not allowed.</p>
<a href="{bp}/config" class="btn"><i data-lucide="arrow-left" class="icon"></i> Back to Config List</a>
"#
);
return Html(base_html("Error", &content));
}
};
let content_value = if canonical_path.exists() {
match std::fs::read_to_string(&canonical_path) {
Ok(c) => html_escape(&c),
Err(e) => format!("# Error reading file: {e}"),
}
} else {
r#"# New pitchfork.toml configuration
# Example:
#
# [daemons.myapp]
# run = "npm start"
# retry = 3
# ready_delay = 5
"#
.to_string()
};
let encoded_path = html_escape(&query.path);
let display_path = html_escape(&path.display().to_string());
let content = format!(
r##"
<div class="page-header">
<h1>Edit: {display_path}</h1>
<div class="header-actions">
<a href="{bp}/config" class="btn btn-sm"><i data-lucide="arrow-left" class="icon"></i> Back</a>
</div>
</div>
<form hx-post="{bp}/config/save" hx-target="#save-result">
<input type="hidden" name="path" value="{encoded_path}">
<div class="form-group">
<textarea name="content" id="config-editor" rows="25">{content_value}</textarea>
</div>
<div class="form-actions">
<button type="button" hx-post="{bp}/config/validate" hx-include="#config-editor, input[name=path]" hx-target="#validation-result" class="btn"><i data-lucide="check-circle" class="icon"></i> Validate</button>
<button type="submit" class="btn btn-primary"><i data-lucide="save" class="icon"></i> Save</button>
</div>
<div id="validation-result"></div>
<div id="save-result"></div>
</form>
"##
);
Html(base_html(&format!("Edit: {}", path.display()), &content))
}
#[derive(Deserialize)]
pub struct ConfigForm {
path: String,
content: String,
}
pub async fn validate(Form(form): Form<ConfigForm>) -> Html<String> {
let path = PathBuf::from(&form.path);
match PitchforkToml::parse_str(&form.content, &path) {
Ok(config) => {
let daemon_count = config.daemons.len();
let daemon_names: Vec<String> = config
.daemons
.keys()
.map(|id| html_escape(id.name()))
.collect();
Html(format!(
r#"
<div class="validation-success">
<strong>Valid!</strong>
<p>Found {daemon_count} daemon(s): {}</p>
</div>
"#,
daemon_names.join(", ")
))
}
Err(e) => Html(format!(
r#"
<div class="validation-error">
<strong>Invalid config</strong>
<pre>{}</pre>
</div>
"#,
html_escape(&format!("{e:?}"))
)),
}
}
pub async fn save(Form(form): Form<ConfigForm>) -> Html<String> {
let path = PathBuf::from(&form.path);
let canonical_path = match validate_path(&path) {
Some(p) => p,
None => {
return Html(r#"<div class="error">This file path is not allowed.</div>"#.to_string());
}
};
if let Err(e) = PitchforkToml::parse_str(&form.content, &path) {
return Html(format!(
r#"
<div class="validation-error">
<strong>Cannot save: Invalid config</strong>
<pre>{}</pre>
</div>
"#,
html_escape(&format!("{e:?}"))
));
}
if let Some(parent) = canonical_path.parent()
&& !parent.exists()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Html(format!(
r#"<div class="error">Failed to create directory: {}</div>"#,
html_escape(&e.to_string())
));
}
match std::fs::write(&canonical_path, &form.content) {
Ok(_) => Html(format!(
r#"
<div class="save-success">
<strong>Saved successfully!</strong>
<p>Configuration saved to {}</p>
</div>
"#,
html_escape(&canonical_path.display().to_string())
)),
Err(e) => Html(format!(
r#"
<div class="error">
<strong>Failed to save</strong>
<p>{}</p>
</div>
"#,
html_escape(&e.to_string())
)),
}
}