use crate::config::repository::{get_config_record, save_config_record}; use crate::config::services::schema_service::get_network_config_field_mappings; use crate::config::types::stored_config::SharedConfigLinkPayload; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use easytier::proto::api::manage::NetworkConfig; use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; use gethostname::gethostname; use std::collections::HashMap; use std::io::{Read, Write}; use url::Url; use uuid::Uuid; const SHARE_LINK_HOST: &str = "easytier.cn"; const SHARE_LINK_PATH: &str = "/comp_cfg"; fn field_name_to_id_map() -> HashMap { get_network_config_field_mappings() .into_iter() .map(|mapping| (mapping.field_name, mapping.field_number.to_string())) .collect() } fn field_id_to_name_map() -> HashMap { get_network_config_field_mappings() .into_iter() .map(|mapping| (mapping.field_number.to_string(), mapping.field_name)) .collect() } fn prune_empty(value: &serde_json::Value) -> Option { match value { serde_json::Value::Null => None, serde_json::Value::Array(values) if values.is_empty() => None, _ => Some(value.clone()), } } fn map_config_json(config: &NetworkConfig) -> Result { let field_name_to_id = field_name_to_id_map(); let raw = serde_json::to_value(config).map_err(|err| err.to_string())?; let mut mapped = serde_json::Map::new(); for (key, value) in raw.as_object().cloned().unwrap_or_default() { let Some(value) = prune_empty(&value) else { continue; }; let mapped_key = field_name_to_id.get(&key).cloned().unwrap_or(key); mapped.insert(mapped_key, value); } serde_json::to_string(&mapped).map_err(|err| err.to_string()) } fn unmap_config_json(raw: &str) -> Result { let field_id_to_name = field_id_to_name_map(); let value = serde_json::from_str::(raw).map_err(|err| err.to_string())?; let mut mapped = serde_json::Map::new(); for (key, value) in value.as_object().cloned().unwrap_or_default() { let field_name = field_id_to_name.get(&key).cloned().unwrap_or(key); mapped.insert(field_name, value); } serde_json::from_value(serde_json::Value::Object(mapped)).map_err(|err| err.to_string()) } fn compress_to_base64url(raw: &str) -> Result { let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); encoder .write_all(raw.as_bytes()) .map_err(|err| err.to_string())?; let compressed = encoder.finish().map_err(|err| err.to_string())?; Ok(URL_SAFE_NO_PAD.encode(compressed)) } fn decompress_from_base64url(raw: &str) -> Result { let compressed = URL_SAFE_NO_PAD.decode(raw).map_err(|err| err.to_string())?; let mut decoder = ZlibDecoder::new(compressed.as_slice()); let mut out = String::new(); decoder .read_to_string(&mut out) .map_err(|err| err.to_string())?; Ok(out) } pub fn build_config_share_link( config_id: &str, display_name: Option, only_start: bool, ) -> Option { let record = get_config_record(config_id)?; let config = serde_json::from_str::(&record.config_json).ok()?; let mapped_json = map_config_json(&config).ok()?; let compressed = compress_to_base64url(&mapped_json).ok()?; let final_name = display_name .or(Some(record.meta.display_name)) .filter(|name| !name.is_empty()); let mut url = Url::parse(&format!("https://{SHARE_LINK_HOST}{SHARE_LINK_PATH}")).ok()?; url.query_pairs_mut().append_pair("cfg", &compressed); if let Some(name) = final_name { url.query_pairs_mut().append_pair("name", &name); } if only_start { url.query_pairs_mut().append_pair("only_start", "true"); } Some(url.to_string()) } pub fn parse_config_share_link(share_link: &str) -> Option { let url = Url::parse(share_link).ok()?; if url.host_str()? != SHARE_LINK_HOST || url.path() != SHARE_LINK_PATH { return None; } let cfg = url .query_pairs() .find(|(key, _)| key == "cfg")? .1 .to_string(); let mapped_json = decompress_from_base64url(&cfg).ok()?; let mut config = unmap_config_json(&mapped_json).ok()?; config.instance_id = Some(Uuid::new_v4().to_string()); let hostname = gethostname().to_string_lossy().to_string(); if !hostname.is_empty() { config.hostname = Some(hostname); } let config_json = serde_json::to_string(&config).ok()?; let display_name = url .query_pairs() .find(|(key, _)| key == "name") .map(|(_, value)| value.to_string()) .filter(|name| !name.is_empty()); let only_start = url .query_pairs() .find(|(key, _)| key == "only_start") .map(|(_, value)| value == "true") .unwrap_or(false); Some(SharedConfigLinkPayload { config_json, display_name, only_start, }) } pub fn import_config_share_link( share_link: &str, display_name_override: Option, ) -> Option { let payload = parse_config_share_link(share_link)?; let config = serde_json::from_str::(&payload.config_json).ok()?; let config_id = config.instance_id.clone()?; let display_name = display_name_override .filter(|name| !name.is_empty()) .or(payload.display_name) .unwrap_or_else(|| config_id.clone()); save_config_record(config_id.clone(), display_name, payload.config_json)?; Some(config_id) } #[cfg(test)] mod tests { use super::*; use crate::config_repo::{create_config_record, init_config_store}; use std::time::{SystemTime, UNIX_EPOCH}; fn test_root() -> String { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir() .join(format!("easytier_ohrs_share_test_{unique}")) .to_string_lossy() .into_owned() } #[test] fn share_link_roundtrip_works() { assert!(init_config_store(test_root())); create_config_record("cfg-share".to_string(), "share-demo".to_string()) .expect("create config"); let link = build_config_share_link("cfg-share", None, true).expect("share link"); let payload = parse_config_share_link(&link).expect("parse link"); let config = serde_json::from_str::(&payload.config_json).expect("config json"); assert!(payload.only_start); assert_eq!(payload.display_name.as_deref(), Some("share-demo")); assert_ne!(config.instance_id.as_deref(), Some("cfg-share")); let imported_id = import_config_share_link(&link, None).expect("import link"); assert_ne!(imported_id, "cfg-share"); } }