diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index 5787ef0c3..d743472d1 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -211,6 +211,34 @@ fn build_panel_url(base_url: &str, query_params: &[(String, String)]) -> Result< Ok(url) } +fn sanitize_panel_api_json_for_log(value: &Value) -> Value { + match value { + Value::Array(arr) => Value::Array(arr.iter().map(sanitize_panel_api_json_for_log).collect()), + Value::Object(obj) => { + let mut out = serde_json::Map::with_capacity(obj.len()); + for (k, v) in obj { + if k.eq_ignore_ascii_case("api_key") || k.eq_ignore_ascii_case("apikey") || k.eq_ignore_ascii_case("token") { + out.insert(k.clone(), Value::String("***".to_string())); + continue; + } + if k.eq_ignore_ascii_case("username") || k.eq_ignore_ascii_case("password") { + out.insert(k.clone(), Value::String("***".to_string())); + continue; + } + if k.eq_ignore_ascii_case("url") { + if let Some(s) = v.as_str() { + out.insert(k.clone(), Value::String(sanitize_sensitive_info(s).into_owned())); + continue; + } + } + out.insert(k.clone(), sanitize_panel_api_json_for_log(v)); + } + Value::Object(out) + } + other => other.clone(), + } +} + async fn panel_get_json(app_state: &AppState, url: Url) -> Result { let client = app_state.http_client.load(); let sanitized = sanitize_sensitive_info(url.as_str()); @@ -227,6 +255,10 @@ async fn panel_get_json(app_state: &AppState, url: Url) -> Result {} + serde_yaml::Value::Null => { + *aliases_value = serde_yaml::Value::Sequence(vec![]); + } + serde_yaml::Value::Mapping(_) => { + // Some users might have a single alias stored as a mapping instead of a list. + // Normalize to a list so we can append safely. + let old = std::mem::replace(aliases_value, serde_yaml::Value::Sequence(vec![])); + if let serde_yaml::Value::Mapping(map) = old { + if let serde_yaml::Value::Sequence(seq) = aliases_value { + seq.push(serde_yaml::Value::Mapping(map)); + } + } + } + _ => { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: input.aliases is not a list in source.yml"); + } } - let Some(alias_seq) = inp_map.get_mut(&aliases_key).and_then(|v| v.as_sequence_mut()) else { + let Some(alias_seq) = aliases_value.as_sequence_mut() else { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "panel_api: input.aliases is not a list in source.yml"); }; @@ -452,12 +503,24 @@ async fn patch_source_yml_update_exp_date( if account_name == input_name { inp_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); inp_map.insert(serde_yaml::Value::String("enabled".to_string()), serde_yaml::Value::Bool(true)); - } else if let Some(aliases) = inp_map.get_mut(serde_yaml::Value::String("aliases".to_string())).and_then(|v| v.as_sequence_mut()) { - for a in aliases.iter_mut() { - let Some(a_map) = a.as_mapping_mut() else { continue; }; - let a_name = a_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); - if a_name == Some(account_name) { - a_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); + } else if let Some(aliases_value) = inp_map.get_mut(serde_yaml::Value::String("aliases".to_string())) { + // Normalize aliases to a sequence if the file contains a single mapping. + if matches!(aliases_value, serde_yaml::Value::Mapping(_)) { + let old = std::mem::replace(aliases_value, serde_yaml::Value::Sequence(vec![])); + if let serde_yaml::Value::Mapping(map) = old { + if let serde_yaml::Value::Sequence(seq) = aliases_value { + seq.push(serde_yaml::Value::Mapping(map)); + } + } + } + + if let Some(aliases) = aliases_value.as_sequence_mut() { + for a in aliases.iter_mut() { + let Some(a_map) = a.as_mapping_mut() else { continue; }; + let a_name = a_map.get(serde_yaml::Value::String("name".to_string())).and_then(|v| v.as_str()); + if a_name == Some(account_name) { + a_map.insert(serde_yaml::Value::String("exp_date".to_string()), serde_yaml::Value::Number(exp_date.into())); + } } } } diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 4a046d0d9..b0a412ea8 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -325,7 +325,7 @@ where .map_err(|err| create_tuliprox_error!(TuliproxErrorKind::Info, "Could not write file {}: {}", &path.to_str().unwrap_or("?"), err)) } -pub fn format_sources_yaml_panel_api_query_params_flow_style(yaml: &str) -> String { +fn format_sources_yaml_panel_api_query_params_flow_style_impl(yaml: &str) -> String { let has_trailing_newline = yaml.ends_with('\n'); let lines: Vec<&str> = yaml.split_terminator('\n').collect(); let mut out: Vec = Vec::with_capacity(lines.len()); @@ -417,6 +417,97 @@ pub fn format_sources_yaml_panel_api_query_params_flow_style(yaml: &str) -> Stri } } +fn parse_yaml_key_value_line(s: &str) -> Option<(String, String)> { + let (key, value) = s.split_once(':')?; + let key = key.trim(); + if key.is_empty() { + return None; + } + let value = value.trim(); + let value = if value.is_empty() { "null" } else { value }; + Some((key.to_string(), value.to_string())) +} + +fn format_sources_yaml_aliases_flow_style_impl(yaml: &str) -> String { + let has_trailing_newline = yaml.ends_with('\n'); + let lines: Vec<&str> = yaml.split_terminator('\n').collect(); + let mut out: Vec = Vec::with_capacity(lines.len()); + + let mut aliases_indent: Option = None; + + let mut i = 0usize; + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim_start(); + let indent = line.len().saturating_sub(trimmed.len()); + + if !trimmed.is_empty() && !trimmed.starts_with('#') { + if let Some(ai) = aliases_indent { + if indent < ai || (indent == ai && trimmed != "aliases:" && !trimmed.starts_with("- ")) { + aliases_indent = None; + } + } + if trimmed == "aliases:" { + aliases_indent = Some(indent); + } + } + + // Convert: + // aliases: + // - name: foo + // url: http://... + // Into: + // - { name: foo, url: http://... } + if aliases_indent.is_some() && trimmed.starts_with("- ") && !trimmed.starts_with("- {") { + let item_indent = indent; + let first = trimmed.strip_prefix("- ").unwrap_or(trimmed); + if let Some((k, v)) = parse_yaml_key_value_line(first) { + let mut parts: Vec<(String, String)> = vec![(k, v)]; + let mut j = i + 1; + while j < lines.len() { + let l2 = lines[j]; + let t2 = l2.trim_start(); + let ind2 = l2.len().saturating_sub(t2.len()); + if t2.is_empty() || t2.starts_with('#') { + break; + } + if ind2 == item_indent.saturating_add(2) && !t2.starts_with("- ") { + if let Some((k2, v2)) = parse_yaml_key_value_line(t2) { + parts.push((k2, v2)); + j += 1; + continue; + } + } + break; + } + + let formatted = parts + .iter() + .map(|(kk, vv)| format!("{kk}: {vv}")) + .collect::>() + .join(", "); + out.push(format!("{}- {{ {} }}", " ".repeat(item_indent), formatted)); + i = j; + continue; + } + } + + out.push(line.to_string()); + i += 1; + } + + if has_trailing_newline { + out.join("\n") + "\n" + } else { + out.join("\n") + } +} + +pub fn format_sources_yaml_panel_api_query_params_flow_style(yaml: &str) -> String { + let yaml = format_sources_yaml_panel_api_query_params_flow_style_impl(yaml); + format_sources_yaml_aliases_flow_style_impl(&yaml) +} + pub async fn save_api_proxy(file_path: &str, backup_dir: &str, config: &ApiProxyConfigDto) -> Result<(), TuliproxError> { write_config_file(file_path, backup_dir, config, "api-proxy.yml").await } @@ -492,4 +583,25 @@ mod tests { assert!(out.contains("- { key: type, value: m3u }")); assert!(!out.contains("- key: api_key\n")); } + + #[test] + fn test_sources_yaml_aliases_flow_style() { + let input = r"sources: +- inputs: + - name: demo + aliases: + - name: demo-u1 + url: http://line.example.invalid + username: u1 + password: p1 + priority: 0 + max_connections: 1 + exp_date: 123 +"; + let out = format_sources_yaml_panel_api_query_params_flow_style(input); + assert!( + out.contains("- { name: demo-u1, url: http://line.example.invalid, username: u1, password: p1, priority: 0, max_connections: 1, exp_date: 123 }"), + "out:\n{out}" + ); + } }