use crate::utils::{ dirs, help, network::{NetworkManager, ProxyType}, tmpl, }; use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; use serde_yaml::Mapping; use std::{fs, time::Duration}; use url::Url; use super::Config; #[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct PrfItem { pub uid: Option, /// profile item type /// enum value: remote | local | script | merge #[serde(rename = "type")] pub itype: Option, /// profile name pub name: Option, /// profile file pub file: Option, /// profile description #[serde(skip_serializing_if = "Option::is_none")] pub desc: Option, /// source url #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, /// selected information #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, /// subscription user info #[serde(skip_serializing_if = "Option::is_none")] pub extra: Option, /// updated time pub updated: Option, /// some options of the item #[serde(skip_serializing_if = "Option::is_none")] pub option: Option, /// profile web page url #[serde(skip_serializing_if = "Option::is_none")] pub home: Option, /// profile support url #[serde(skip_serializing_if = "Option::is_none")] pub support_url: Option, /// profile announce #[serde(skip_serializing_if = "Option::is_none")] pub announce: Option, /// profile announce url #[serde(skip_serializing_if = "Option::is_none")] pub announce_url: Option, /// the file data #[serde(skip)] pub file_data: Option, } #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct PrfSelected { pub name: Option, pub now: Option, } #[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)] pub struct PrfExtra { pub upload: u64, pub download: u64, pub total: u64, pub expire: u64, } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct PrfOption { /// for `remote` profile's http request /// see issue #13 #[serde(skip_serializing_if = "Option::is_none")] pub user_agent: Option, /// for `remote` profile /// use system proxy #[serde(skip_serializing_if = "Option::is_none")] pub with_proxy: Option, /// for `remote` profile /// use self proxy #[serde(skip_serializing_if = "Option::is_none")] pub self_proxy: Option, #[serde(skip_serializing_if = "Option::is_none")] pub update_interval: Option, /// for `remote` profile /// HTTP request timeout in seconds /// default is 60 seconds #[serde(skip_serializing_if = "Option::is_none")] pub timeout_seconds: Option, /// for `remote` profile /// disable certificate validation /// default is `false` #[serde(skip_serializing_if = "Option::is_none")] pub danger_accept_invalid_certs: Option, pub merge: Option, pub script: Option, pub rules: Option, pub proxies: Option, pub groups: Option, #[serde(skip_serializing_if = "Option::is_none")] pub use_hwid: Option, #[serde(skip_serializing_if = "Option::is_none")] pub update_always: Option, } impl PrfOption { pub fn merge(one: Option, other: Option) -> Option { match (one, other) { (Some(mut a), Some(b)) => { a.user_agent = b.user_agent.or(a.user_agent); a.with_proxy = b.with_proxy.or(a.with_proxy); a.self_proxy = b.self_proxy.or(a.self_proxy); a.danger_accept_invalid_certs = b .danger_accept_invalid_certs .or(a.danger_accept_invalid_certs); a.update_interval = b.update_interval.or(a.update_interval); a.merge = b.merge.or(a.merge); a.script = b.script.or(a.script); a.rules = b.rules.or(a.rules); a.proxies = b.proxies.or(a.proxies); a.groups = b.groups.or(a.groups); a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds); a.use_hwid = b.use_hwid.or(a.use_hwid); a.update_always = b.update_always.or(a.update_always); Some(a) } t => t.0.or(t.1), } } } impl PrfItem { /// From partial item /// must contain `itype` pub async fn from(item: PrfItem, file_data: Option) -> Result { if item.itype.is_none() { bail!("type should not be null"); } match item.itype.unwrap().as_str() { "remote" => { if item.url.is_none() { bail!("url should not be null"); } let url = item.url.as_ref().unwrap().as_str(); let name = item.name; let desc = item.desc; PrfItem::from_url(url, name, desc, item.option).await } "local" => { let name = item.name.unwrap_or("Local File".into()); let desc = item.desc.unwrap_or("".into()); PrfItem::from_local(name, desc, file_data, item.option) } typ => bail!("invalid profile item type \"{typ}\""), } } /// ## Local type /// create a new item from name/desc pub fn from_local( name: String, desc: String, file_data: Option, option: Option, ) -> Result { let uid = help::get_uid("L"); let file = format!("{uid}.yaml"); let opt_ref = option.as_ref(); let update_interval = opt_ref.and_then(|o| o.update_interval); let mut merge = opt_ref.and_then(|o| o.merge.clone()); let mut script = opt_ref.and_then(|o| o.script.clone()); let mut rules = opt_ref.and_then(|o| o.rules.clone()); let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut groups = opt_ref.and_then(|o| o.groups.clone()); if merge.is_none() { let merge_item = PrfItem::from_merge(None)?; Config::profiles().data().append_item(merge_item.clone())?; merge = merge_item.uid; } if script.is_none() { let script_item = PrfItem::from_script(None)?; Config::profiles().data().append_item(script_item.clone())?; script = script_item.uid; } if rules.is_none() { let rules_item = PrfItem::from_rules()?; Config::profiles().data().append_item(rules_item.clone())?; rules = rules_item.uid; } if proxies.is_none() { let proxies_item = PrfItem::from_proxies()?; Config::profiles() .data() .append_item(proxies_item.clone())?; proxies = proxies_item.uid; } if groups.is_none() { let groups_item = PrfItem::from_groups()?; Config::profiles().data().append_item(groups_item.clone())?; groups = groups_item.uid; } Ok(PrfItem { uid: Some(uid), itype: Some("local".into()), name: Some(name), desc: Some(desc), file: Some(file), url: None, selected: None, extra: None, option: Some(PrfOption { update_interval, merge, script, rules, proxies, groups, ..PrfOption::default() }), home: None, support_url: None, announce: None, announce_url: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(file_data.unwrap_or(tmpl::ITEM_LOCAL.into())), }) } /// ## Remote type /// create a new item from url pub async fn from_url( url: &str, name: Option, desc: Option, option: Option, ) -> Result { let opt_ref = option.as_ref(); let with_proxy = opt_ref.is_some_and(|o| o.with_proxy.unwrap_or(false)); let self_proxy = opt_ref.is_some_and(|o| o.self_proxy.unwrap_or(false)); let accept_invalid_certs = opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false)); let user_agent = opt_ref.and_then(|o| o.user_agent.clone()); let update_interval = opt_ref.and_then(|o| o.update_interval); let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(20); let use_hwid = Config::verge().latest().enable_send_hwid.unwrap_or(true); let mut merge = opt_ref.and_then(|o| o.merge.clone()); let mut script = opt_ref.and_then(|o| o.script.clone()); let mut rules = opt_ref.and_then(|o| o.rules.clone()); let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut groups = opt_ref.and_then(|o| o.groups.clone()); // 选择代理类型 let proxy_type = if self_proxy { ProxyType::Localhost } else if with_proxy { ProxyType::System } else { ProxyType::None }; // 使用网络管理器发送请求 let resp = match NetworkManager::global() .get_with_interrupt( url, proxy_type, Some(timeout), user_agent.clone(), accept_invalid_certs, use_hwid, ) .await { Ok(r) => r, Err(e) => { tokio::time::sleep(Duration::from_millis(100)).await; bail!("failed to fetch remote profile: {}", e); } }; let status_code = resp.status(); if !StatusCode::is_success(&status_code) { bail!("failed to fetch remote profile with status {status_code}") } let header = resp.headers(); let mut final_url = url.to_string(); if let Some(new_domain_value) = header.get("new-sub-domain") { if let Ok(new_domain) = new_domain_value.to_str() { if !new_domain.is_empty() { if let Ok(mut parsed_url) = Url::parse(url) { if parsed_url.set_host(Some(new_domain)).is_ok() { final_url = parsed_url.to_string(); log::info!(target: "app", "URL host updated to -> {final_url}"); } } } } } // parse the Subscription UserInfo let extra = match header.get("Subscription-Userinfo") { Some(value) => { let sub_info = value.to_str().unwrap_or(""); Some(PrfExtra { upload: help::parse_str(sub_info, "upload").unwrap_or(0), download: help::parse_str(sub_info, "download").unwrap_or(0), total: help::parse_str(sub_info, "total").unwrap_or(0), expire: help::parse_str(sub_info, "expire").unwrap_or(0), }) } None => None, }; // parse the Content-Disposition let filename = match header.get("Content-Disposition") { Some(value) => { let filename = format!("{value:?}"); let filename = filename.trim_matches('"'); match help::parse_str::(filename, "filename*") { Some(filename) => { let iter = percent_encoding::percent_decode(filename.as_bytes()); let filename = iter.decode_utf8().unwrap_or_default(); filename.split("''").last().map(|s| s.to_string()) } None => match help::parse_str::(filename, "filename") { Some(filename) => { let filename = filename.trim_matches('"'); Some(filename.to_string()) } None => None, }, } } None => Some( crate::utils::help::get_last_part_and_decode(url).unwrap_or("Remote File".into()), ), }; let update_interval = match update_interval { Some(val) => Some(val), None => match header.get("profile-update-interval") { Some(value) => match value.to_str().unwrap_or("").parse::() { Ok(val) => Some(val * 60), // hour -> min Err(_) => None, }, None => None, }, }; let update_always = match header.get("update-always") { Some(value) => value.to_str().unwrap_or("false").parse::().ok(), None => None, }; let home = match header.get("profile-web-page-url") { Some(value) => { let str_value = value.to_str().unwrap_or(""); Some(str_value.to_string()) } None => None, }; let support_url = match header.get("support-url") { Some(value) => { let str_value = value.to_str().unwrap_or(""); Some(str_value.to_string()) } None => None, }; let announce = match header.get("announce") { Some(value) => { let str_value = value.to_str().unwrap_or(""); if let Some(b64_data) = str_value.strip_prefix("base64:") { STANDARD .decode(b64_data) .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) } else { Some(str_value.to_string()) } } None => None, }; if let Some(announce_msg) = &announce { let lower_msg = announce_msg.to_lowercase(); if lower_msg.contains("device") || lower_msg.contains("устройств") { bail!(announce_msg.clone()); } } let announce_url = match header.get("announce-url") { Some(value) => { let str_value = value.to_str().unwrap_or(""); Some(str_value.to_string()) } None => None, }; let profile_title = match header.get("profile-title") { Some(value) => { let str_value = value.to_str().unwrap_or(""); if let Some(b64_data) = str_value.strip_prefix("base64:") { STANDARD .decode(b64_data) .ok() .and_then(|bytes| String::from_utf8(bytes).ok()) } else { Some(str_value.to_string()) } } None => None, }; let uid = help::get_uid("R"); let file = format!("{uid}.yaml"); let name = name .or(profile_title) .unwrap_or(filename.unwrap_or("Remote File".into())); let data = resp.text_with_charset("utf-8").await?; // process the charset "UTF-8 with BOM" let data = data.trim_start_matches('\u{feff}'); // check the data whether the valid yaml format let yaml = serde_yaml::from_str::(data) .context("the remote profile data is invalid yaml")?; if !yaml.contains_key("proxies") && !yaml.contains_key("proxy-providers") { bail!("profile does not contain `proxies` or `proxy-providers`"); } if merge.is_none() { let merge_item = PrfItem::from_merge(None)?; Config::profiles().data().append_item(merge_item.clone())?; merge = merge_item.uid; } if script.is_none() { let script_item = PrfItem::from_script(None)?; Config::profiles().data().append_item(script_item.clone())?; script = script_item.uid; } if rules.is_none() { let rules_item = PrfItem::from_rules()?; Config::profiles().data().append_item(rules_item.clone())?; rules = rules_item.uid; } if proxies.is_none() { let proxies_item = PrfItem::from_proxies()?; Config::profiles() .data() .append_item(proxies_item.clone())?; proxies = proxies_item.uid; } if groups.is_none() { let groups_item = PrfItem::from_groups()?; Config::profiles().data().append_item(groups_item.clone())?; groups = groups_item.uid; } Ok(PrfItem { uid: Some(uid), itype: Some("remote".into()), name: Some(name), desc, file: Some(file), url: Some(final_url), selected: None, extra, option: Some(PrfOption { update_interval, update_always, merge, script, rules, proxies, groups, ..PrfOption::default() }), home, support_url, announce, announce_url, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(data.into()), }) } /// ## Merge type (enhance) /// create the enhanced item by using `merge` rule pub fn from_merge(uid: Option) -> Result { let mut id = help::get_uid("m"); let mut template = tmpl::ITEM_MERGE_EMPTY.into(); if let Some(uid) = uid { id = uid; template = tmpl::ITEM_MERGE.into(); } let file = format!("{id}.yaml"); Ok(PrfItem { uid: Some(id), itype: Some("merge".into()), name: None, desc: None, file: Some(file), url: None, selected: None, extra: None, option: None, home: None, support_url: None, announce: None, announce_url: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(template), }) } /// ## Script type (enhance) /// create the enhanced item by using javascript quick.js pub fn from_script(uid: Option) -> Result { let mut id = help::get_uid("s"); if let Some(uid) = uid { id = uid; } let file = format!("{id}.js"); // js ext Ok(PrfItem { uid: Some(id), itype: Some("script".into()), name: None, desc: None, file: Some(file), url: None, home: None, support_url: None, announce: None, announce_url: None, selected: None, extra: None, option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_SCRIPT.into()), }) } /// ## Rules type (enhance) pub fn from_rules() -> Result { let uid = help::get_uid("r"); let file = format!("{uid}.yaml"); // yaml ext Ok(PrfItem { uid: Some(uid), itype: Some("rules".into()), name: None, desc: None, file: Some(file), url: None, home: None, support_url: None, announce: None, announce_url: None, selected: None, extra: None, option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_RULES.into()), }) } /// ## Proxies type (enhance) pub fn from_proxies() -> Result { let uid = help::get_uid("p"); let file = format!("{uid}.yaml"); // yaml ext Ok(PrfItem { uid: Some(uid), itype: Some("proxies".into()), name: None, desc: None, file: Some(file), url: None, home: None, support_url: None, announce: None, announce_url: None, selected: None, extra: None, option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_PROXIES.into()), }) } /// ## Groups type (enhance) pub fn from_groups() -> Result { let uid = help::get_uid("g"); let file = format!("{uid}.yaml"); // yaml ext Ok(PrfItem { uid: Some(uid), itype: Some("groups".into()), name: None, desc: None, file: Some(file), url: None, home: None, support_url: None, announce: None, announce_url: None, selected: None, extra: None, option: None, updated: Some(chrono::Local::now().timestamp() as usize), file_data: Some(tmpl::ITEM_GROUPS.into()), }) } /// get the file data pub fn read_file(&self) -> Result { if self.file.is_none() { bail!("could not find the file"); } let file = self.file.clone().unwrap(); let path = dirs::app_profiles_dir()?.join(file); fs::read_to_string(path).context("failed to read the file") } /// save the file data pub fn save_file(&self, data: String) -> Result<()> { if self.file.is_none() { bail!("could not find the file"); } let file = self.file.clone().unwrap(); let path = dirs::app_profiles_dir()?.join(file); fs::write(path, data.as_bytes()).context("failed to save the file") } }