libsubconverter/api/
sub.rs

1use log::{debug, error};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::constants::regex_black_list::REGEX_BLACK_LIST;
6use crate::interfaces::subconverter::{subconverter, SubconverterConfigBuilder, UploadStatus};
7use crate::models::ruleset::RulesetConfigs;
8use crate::models::{ProxyGroupConfigs, RegexMatchConfigs, SubconverterTarget};
9use crate::settings::external::ExternalSettings;
10use crate::settings::settings::init_settings;
11use crate::settings::{refresh_configuration, FromIni, FromIniWithDelimiter};
12use crate::utils::reg_valid;
13use crate::{RuleBases, Settings, TemplateArgs};
14
15#[cfg(target_arch = "wasm32")]
16use {js_sys::Promise, wasm_bindgen::prelude::*, wasm_bindgen_futures::future_to_promise};
17
18fn default_ver() -> u32 {
19    3
20}
21
22/// Query parameters for subscription conversion
23#[derive(Deserialize, Serialize, Debug, Default, Clone)]
24pub struct SubconverterQuery {
25    /// Target format
26    pub target: Option<String>,
27    /// Surge version number
28    #[serde(default = "default_ver")]
29    pub ver: u32,
30    /// Clash new field name
31    pub new_name: Option<bool>,
32    /// URLs to convert (pipe separated)
33    pub url: Option<String>,
34    /// Custom group name
35    pub group: Option<String>,
36    /// Upload path (optional)
37    pub upload_path: Option<String>,
38    /// Include remarks regex, multiple regexes separated by '|'
39    pub include: Option<String>,
40    /// Exclude remarks regex, multiple regexes separated by '|'
41    pub exclude: Option<String>,
42    /// custom groups
43    pub groups: Option<String>,
44    /// Ruleset contents
45    pub ruleset: Option<String>,
46    /// External configuration file (optional)
47    pub config: Option<String>,
48
49    /// Device ID (for device-specific configurations)
50    pub dev_id: Option<String>,
51    /// Whether to insert nodes
52    pub insert: Option<bool>,
53    /// Whether to prepend insert nodes
54    pub prepend: Option<bool>,
55    /// Custom filename for download
56    pub filename: Option<String>,
57    /// Append proxy type to remarks
58    pub append_type: Option<bool>,
59    /// Whether to remove old emoji and add new emoji
60    pub emoji: Option<bool>,
61    /// Whether to add emoji
62    pub add_emoji: Option<bool>,
63    /// Whether to remove emoji
64    pub remove_emoji: Option<bool>,
65    /// List mode (node list only)
66    pub list: Option<bool>,
67    /// Sort nodes
68    pub sort: Option<bool>,
69
70    /// Sort Script
71    pub sort_script: Option<String>,
72
73    /// argFilterDeprecated
74    pub fdn: Option<bool>,
75
76    /// Information for filtering, rename, emoji addition
77    pub rename: Option<String>,
78    /// Whether to enable TCP Fast Open
79    pub tfo: Option<bool>,
80    /// Whether to enable UDP
81    pub udp: Option<bool>,
82    /// Whether to skip certificate verification
83    pub scv: Option<bool>,
84    /// Whether to enable TLS 1.3
85    pub tls13: Option<bool>,
86    /// Enable rule generator
87    pub rename_node: Option<bool>,
88    /// Update interval in seconds
89    pub interval: Option<u32>,
90    /// Update strict mode
91    pub strict: Option<bool>,
92    /// Upload to gist
93    pub upload: Option<bool>,
94    /// Authentication token
95    pub token: Option<String>,
96    /// Filter script
97    pub filter: Option<String>,
98
99    /// Clash script
100    pub script: Option<bool>,
101    pub classic: Option<bool>,
102
103    pub expand: Option<bool>,
104
105    /// Singbox specific parameters
106    #[serde(default)]
107    pub singbox: HashMap<String, String>,
108
109    /// Request headers
110    pub request_headers: Option<HashMap<String, String>>,
111}
112
113/// Parse a query string into a HashMap
114pub fn parse_query_string(query: &str) -> HashMap<String, String> {
115    let mut params = HashMap::new();
116    for pair in query.split('&') {
117        let mut parts = pair.splitn(2, '=');
118        if let Some(key) = parts.next() {
119            let value = parts.next().unwrap_or("");
120            params.insert(key.to_string(), value.to_string());
121        }
122    }
123    params
124}
125
126/// Struct to represent a subscription process response
127#[derive(Debug, Serialize)]
128pub struct SubResponse {
129    pub content: String,
130    pub content_type: String,
131    pub headers: HashMap<String, String>,
132    pub status_code: u16,
133    #[serde(skip_serializing_if = "is_not_attempted")] // Don't include if upload wasn't attempted
134    pub upload_status: UploadStatus,
135}
136
137// Helper function for skip_serializing_if
138fn is_not_attempted(status: &UploadStatus) -> bool {
139    matches!(status, UploadStatus::NotAttempted)
140}
141
142impl SubResponse {
143    pub fn ok(content: String, content_type: String) -> Self {
144        Self {
145            content,
146            content_type,
147            headers: HashMap::new(),
148            status_code: 200,
149            upload_status: UploadStatus::NotAttempted, // Default to not attempted
150        }
151    }
152
153    pub fn error(content: String, status_code: u16) -> Self {
154        Self {
155            content,
156            content_type: "text/plain".to_string(),
157            headers: HashMap::new(),
158            status_code,
159            upload_status: UploadStatus::NotAttempted, // Default to not attempted
160        }
161    }
162
163    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
164        self.headers = headers;
165        self
166    }
167
168    pub fn with_upload_status(mut self, status: UploadStatus) -> Self {
169        self.upload_status = status;
170        self
171    }
172}
173
174/// Handler for subscription conversion
175pub async fn sub_process(
176    req_url: Option<String>,
177    query: SubconverterQuery,
178) -> Result<SubResponse, Box<dyn std::error::Error>> {
179    let mut global = Settings::current();
180
181    // not initialized, in wasm that's common for cold start.
182    if global.pref_path.is_empty() {
183        debug!("Global config not initialized, reloading");
184        init_settings("").await?;
185        global = Settings::current();
186    } else if global.reload_conf_on_request && !global.api_mode && !global.generator_mode {
187        refresh_configuration().await;
188        global = Settings::current();
189    }
190
191    // Start building configuration
192    let mut builder = SubconverterConfigBuilder::new();
193
194    let target;
195    if let Some(_target) = &query.target {
196        match SubconverterTarget::from_str(&_target) {
197            Some(_target) => {
198                target = _target.clone();
199                if _target == SubconverterTarget::Auto {
200                    // TODO: Check user agent and set target accordingly
201                    // if let Some(user_agent) = req.headers().get("User-Agent") {
202                    //     if let Ok(user_agent) = user_agent.to_str() {
203
204                    //         // match_user_agent(
205                    //         //     user_agent,
206                    //         //     &target,
207                    //         //      query.new_name,
208                    //         //      &query.ver);
209                    //     }
210                    // }
211                    return Ok(SubResponse::error(
212                        "Auto user agent is not supported for now.".to_string(),
213                        400,
214                    ));
215                }
216                builder.target(_target);
217            }
218            None => {
219                return Ok(SubResponse::error(
220                    "Invalid target parameter".to_string(),
221                    400,
222                ));
223            }
224        }
225    } else {
226        return Ok(SubResponse::error(
227            "Missing target parameter".to_string(),
228            400,
229        ));
230    }
231
232    builder.update_interval(match query.interval {
233        Some(interval) => interval,
234        None => global.update_interval,
235    });
236    // Check if we should authorize the request, if we are in API mode
237    #[cfg(not(target_arch = "wasm32"))]
238    let authorized = false;
239
240    #[cfg(target_arch = "wasm32")]
241    let authorized =
242        !global.api_mode || query.token.as_deref().unwrap_or_default() == global.api_access_token;
243    builder.authorized(authorized);
244    builder.update_strict(query.strict.unwrap_or(global.update_strict));
245
246    if query
247        .include
248        .clone()
249        .is_some_and(|include| REGEX_BLACK_LIST.contains(&include))
250        || query
251            .exclude
252            .clone()
253            .is_some_and(|exclude| REGEX_BLACK_LIST.contains(&exclude))
254    {
255        return Ok(SubResponse::error(
256            "Invalid regex in request!".to_string(),
257            400,
258        ));
259    }
260
261    let enable_insert = match query.insert {
262        Some(insert) => insert,
263        None => global.enable_insert,
264    };
265
266    if enable_insert {
267        builder.insert_urls(global.insert_urls.clone());
268        // 加在前面还是加在后面
269        builder.prepend_insert(query.prepend.unwrap_or(global.prepend_insert));
270    }
271
272    let urls = match query.url.as_deref() {
273        Some(query_url) => query_url.split('|').map(|s| s.to_owned()).collect(),
274        None => {
275            if authorized {
276                global.default_urls.clone()
277            } else {
278                vec![]
279            }
280        }
281    };
282    builder.urls(urls);
283
284    // TODO: what if urls still empty after insert?
285
286    // Create template args from request parameters and other settings
287    let mut template_args = TemplateArgs::default();
288    template_args.global_vars = global.template_vars.clone();
289
290    template_args.request_params = query.clone();
291
292    builder.append_proxy_type(query.append_type.unwrap_or(global.append_type));
293
294    let mut arg_expand_rulesets = query.expand;
295    if target.is_clash() && query.script.is_none() {
296        arg_expand_rulesets = Some(true);
297    }
298
299    // flags
300    builder.tfo(query.tfo.or(global.tfo_flag));
301    builder.udp(query.udp.or(global.udp_flag));
302    builder.skip_cert_verify(query.scv.or(global.skip_cert_verify));
303    builder.tls13(query.tls13.or(global.tls13_flag));
304    builder.sort(query.sort.unwrap_or(global.enable_sort));
305    builder.sort_script(query.sort_script.unwrap_or(global.sort_script.clone()));
306
307    builder.filter_deprecated(query.fdn.unwrap_or(global.filter_deprecated));
308    builder.clash_new_field_name(query.new_name.unwrap_or(global.clash_use_new_field));
309    builder.clash_script(query.script.unwrap_or_default());
310    builder.clash_classical_ruleset(query.classic.unwrap_or_default());
311    let nodelist = query.list.unwrap_or_default();
312    builder.nodelist(nodelist);
313
314    if arg_expand_rulesets != Some(true) {
315        builder.clash_new_field_name(true);
316    } else {
317        builder.managed_config_prefix(global.managed_config_prefix.clone());
318        builder.clash_script(false);
319    }
320
321    let mut ruleset_configs = global.custom_rulesets.clone();
322    let mut custom_group_configs = global.custom_proxy_groups.clone();
323
324    // 这部分参数有优先级:query > external > global
325    builder.include_remarks(global.include_remarks.clone());
326    builder.exclude_remarks(global.exclude_remarks.clone());
327    builder.rename_array(global.renames.clone());
328    builder.emoji_array(global.emojis.clone());
329    builder.add_emoji(global.add_emoji);
330    builder.remove_emoji(global.remove_emoji);
331    builder.enable_rule_generator(global.enable_rule_gen);
332    let mut rule_bases = RuleBases {
333        clash_rule_base: global.clash_base.clone(),
334        surge_rule_base: global.surge_base.clone(),
335        surfboard_rule_base: global.surfboard_base.clone(),
336        mellow_rule_base: global.mellow_base.clone(),
337        quan_rule_base: global.quan_base.clone(),
338        quanx_rule_base: global.quanx_base.clone(),
339        loon_rule_base: global.loon_base.clone(),
340        sssub_rule_base: global.ssub_base.clone(),
341        singbox_rule_base: global.singbox_base.clone(),
342    };
343    builder.rule_bases(rule_bases.clone());
344    builder.template_args(template_args.clone());
345
346    let ext_config = match query.config.as_deref() {
347        Some(config) => config.to_owned(),
348        None => global.default_ext_config.clone(),
349    };
350    if !ext_config.is_empty() {
351        debug!("Loading external config from {}", ext_config);
352
353        // In WebAssembly environment, we can't use std::thread::spawn
354        // Instead, we use the async version directly
355        let extconf_result = ExternalSettings::load_from_file(&ext_config).await;
356
357        match extconf_result {
358            Ok(extconf) => {
359                debug!("Successfully loaded external config from {}", ext_config);
360                if !nodelist {
361                    rule_bases
362                        .check_external_bases(&extconf, &global.base_path)
363                        .await;
364                    builder.rule_bases(rule_bases);
365
366                    if let Some(tpl_args) = extconf.tpl_args {
367                        template_args.local_vars = tpl_args;
368                    }
369
370                    builder.template_args(template_args);
371
372                    if !target.is_simple() {
373                        if !extconf.custom_rulesets.is_empty() {
374                            ruleset_configs = extconf.custom_rulesets;
375                        }
376                        if !extconf.custom_proxy_groups.is_empty() {
377                            custom_group_configs = extconf.custom_proxy_groups;
378                        }
379                        if let Some(enable_rule_gen) = extconf.enable_rule_generator {
380                            builder.enable_rule_generator(enable_rule_gen);
381                        }
382                        if let Some(overwrite_original_rules) = extconf.overwrite_original_rules {
383                            builder.overwrite_original_rules(overwrite_original_rules);
384                        }
385                    }
386                }
387                if !extconf.rename_nodes.is_empty() {
388                    builder.rename_array(extconf.rename_nodes);
389                }
390                if !extconf.emojis.is_empty() {
391                    builder.emoji_array(extconf.emojis);
392                }
393                if !extconf.include_remarks.is_empty() {
394                    builder.include_remarks(extconf.include_remarks);
395                }
396                if !extconf.exclude_remarks.is_empty() {
397                    builder.exclude_remarks(extconf.exclude_remarks);
398                }
399                if extconf.add_emoji.is_some() {
400                    builder.add_emoji(extconf.add_emoji.unwrap());
401                }
402                if extconf.remove_old_emoji.is_some() {
403                    builder.remove_emoji(extconf.remove_old_emoji.unwrap());
404                }
405            }
406            Err(e) => {
407                error!("Failed to load external config from {}: {}", ext_config, e);
408            }
409        }
410    }
411
412    // 请求参数的覆盖优先级最高
413    if let Some(include) = query.include.as_deref() {
414        if reg_valid(&include) {
415            builder.include_remarks(vec![include.to_owned()]);
416        }
417    }
418    if let Some(exclude) = query.exclude.as_deref() {
419        if reg_valid(&exclude) {
420            builder.exclude_remarks(vec![exclude.to_owned()]);
421        }
422    }
423    if let Some(emoji) = query.emoji {
424        builder.add_emoji(emoji);
425        builder.remove_emoji(true);
426    }
427
428    if let Some(add_emoji) = query.add_emoji {
429        builder.add_emoji(add_emoji);
430    }
431    if let Some(remove_emoji) = query.remove_emoji {
432        builder.remove_emoji(remove_emoji);
433    }
434    if let Some(rename) = query.rename.as_deref() {
435        if !rename.is_empty() {
436            let v_array: Vec<String> = rename.split('`').map(|s| s.to_string()).collect();
437            builder.rename_array(RegexMatchConfigs::from_ini_with_delimiter(&v_array, "@"));
438        }
439    }
440
441    if !target.is_simple() {
442        // loading custom groups
443        if !query
444            .groups
445            .as_deref()
446            .is_none_or(|groups| groups.is_empty())
447            && !nodelist
448        {
449            if let Some(groups) = query.groups.as_deref() {
450                let v_array: Vec<String> = groups.split('@').map(|s| s.to_string()).collect();
451                custom_group_configs = ProxyGroupConfigs::from_ini(&v_array);
452            }
453        }
454        // loading custom rulesets
455        if !query
456            .ruleset
457            .as_deref()
458            .is_none_or(|ruleset| ruleset.is_empty())
459            && !nodelist
460        {
461            if let Some(ruleset) = query.ruleset.as_deref() {
462                let v_array: Vec<String> = ruleset.split('@').map(|s| s.to_string()).collect();
463                ruleset_configs = RulesetConfigs::from_ini(&v_array);
464            }
465        }
466    }
467    builder.proxy_groups(custom_group_configs);
468    builder.ruleset_configs(ruleset_configs);
469
470    // TODO: process with the script runtime
471
472    // parse settings
473
474    // Process group name
475    builder.group_name(query.group.clone());
476    builder.filename(query.filename.clone());
477    builder.upload(query.upload.unwrap_or_default());
478
479    // Process filter script
480    let filter = query.filter.unwrap_or(global.filter_script.clone());
481    if !filter.is_empty() {
482        builder.filter_script(Some(filter));
483    }
484
485    // // Process device ID
486    // if let Some(dev_id) = &query.dev_id {
487    //     builder.device_id(Some(dev_id.clone()));
488    // }
489
490    // // Set managed config prefix from global settings
491    // if !global.managed_config_prefix.is_empty() {
492    //     builder =
493    // builder.managed_config_prefix(global.managed_config_prefix.clone()); }
494
495    if let Some(request_headers) = &query.request_headers {
496        builder.request_headers(request_headers.clone());
497    }
498
499    // Build and validate configuration
500    let config = match builder.build() {
501        Ok(cfg) => cfg,
502        Err(e) => {
503            error!("Failed to build subconverter config: {}", e);
504            return Ok(SubResponse::error(
505                format!("Configuration error: {}", e),
506                400,
507            ));
508        }
509    };
510
511    // Run subconverter directly instead of spawning a thread
512    // This is necessary for WebAssembly compatibility
513    debug!("Running subconverter with config: {:?}", config);
514    let subconverter_result = subconverter(config).await;
515
516    match subconverter_result {
517        Ok(result) => {
518            // Determine content type based on target
519            let content_type = match target {
520                SubconverterTarget::Clash
521                | SubconverterTarget::ClashR
522                | SubconverterTarget::SingBox => "application/yaml",
523                SubconverterTarget::SSSub | SubconverterTarget::SSD => "application/json",
524                _ => "text/plain",
525            };
526
527            debug!("Subconverter completed successfully");
528            Ok(SubResponse::ok(result.content, content_type.to_string())
529                .with_headers(result.headers)
530                .with_upload_status(result.upload_status))
531        }
532        Err(e) => {
533            error!("Subconverter error: {}", e);
534            Ok(SubResponse::error(format!("Conversion error: {}", e), 500))
535        }
536    }
537}
538
539#[cfg(target_arch = "wasm32")]
540#[wasm_bindgen]
541pub fn sub_process_wasm(query_json: &str) -> Promise {
542    // Parse the query from JSON
543    let query = match serde_json::from_str::<SubconverterQuery>(query_json) {
544        Ok(q) => q,
545        Err(e) => {
546            return Promise::reject(&JsValue::from_str(&format!("Failed to parse query: {}", e)));
547        }
548    };
549
550    let query_json_string = Some(query_json.to_string());
551    // Create a future for the async sub_process
552    let future = async move {
553        match sub_process(None, query).await {
554            Ok(response) => {
555                // Convert the SubResponse to JSON string
556                match serde_json::to_string(&response) {
557                    Ok(json) => Ok(JsValue::from_str(&json)),
558                    Err(e) => Err(JsValue::from_str(&format!(
559                        "Failed to serialize response: {}",
560                        e
561                    ))),
562                }
563            }
564            Err(e) => Err(JsValue::from_str(&format!(
565                "Subscription processing error: {}",
566                e
567            ))),
568        }
569    };
570
571    // Convert the future to a JavaScript Promise
572    future_to_promise(future)
573}
574
575#[cfg(target_arch = "wasm32")]
576#[wasm_bindgen]
577pub fn init_settings_wasm(pref_path: &str) -> Promise {
578    let pref_path = pref_path.to_string();
579    let future = async move {
580        match init_settings(&pref_path).await {
581            Ok(_) => Ok(JsValue::from_bool(true)),
582            Err(e) => Err(JsValue::from_str(&format!(
583                "Failed to initialize settings: {}",
584                e
585            ))),
586        }
587    };
588
589    future_to_promise(future)
590}