1use std::{fmt, string::FromUtf8Error};
2
3use base64::{Engine as _, engine::general_purpose::STANDARD};
4use reqwest::header::{self, HeaderMap};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use thiserror::Error;
8
9#[derive(Clone, Default, Eq, PartialEq)]
11pub struct SubscriptionDevice {
12 hwid: String,
13 pub user_agent: String,
15 pub device_os: String,
17 pub os_version: String,
19 pub device_model: String,
21}
22
23impl SubscriptionDevice {
24 pub fn new(hwid: impl Into<String>, user_agent: impl Into<String>) -> Self {
26 Self {
27 hwid: hwid.into(),
28 user_agent: user_agent.into(),
29 ..Self::default()
30 }
31 }
32
33 pub fn hwid(&self) -> &str {
35 &self.hwid
36 }
37}
38
39impl fmt::Debug for SubscriptionDevice {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 formatter
42 .debug_struct("SubscriptionDevice")
43 .field("hwid", &"[REDACTED]")
44 .field("user_agent", &self.user_agent)
45 .field("device_os", &self.device_os)
46 .field("os_version", &self.os_version)
47 .field("device_model", &self.device_model)
48 .finish()
49 }
50}
51
52#[derive(Debug, Error)]
54#[non_exhaustive]
55pub enum SubscriptionDecodeError {
56 #[error("subscription body is not valid base64: {0}")]
58 Base64(#[from] base64::DecodeError),
59 #[error("decoded subscription body is not valid UTF-8: {0}")]
61 Utf8(#[from] FromUtf8Error),
62}
63
64#[derive(Clone, Default, Eq, PartialEq)]
66pub struct SubscriptionDocument(String);
67
68impl SubscriptionDocument {
69 pub(crate) fn new(value: String) -> Self {
70 Self(value)
71 }
72
73 pub fn as_str(&self) -> &str {
75 &self.0
76 }
77
78 pub fn into_string(self) -> String {
80 self.0
81 }
82
83 pub fn decode_base64(&self) -> Result<Self, SubscriptionDecodeError> {
90 let bytes = STANDARD.decode(self.0.trim())?;
91 String::from_utf8(bytes).map(Self).map_err(Into::into)
92 }
93
94 pub fn lines(&self) -> impl Iterator<Item = &str> {
96 self.0
97 .lines()
98 .map(str::trim)
99 .filter(|line| !line.is_empty())
100 }
101}
102
103impl fmt::Debug for SubscriptionDocument {
104 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
105 formatter
106 .debug_tuple("SubscriptionDocument")
107 .field(&"[REDACTED]")
108 .finish()
109 }
110}
111
112#[derive(Clone, Deserialize, PartialEq, Serialize)]
114#[serde(transparent)]
115pub struct SubscriptionJson(Value);
116
117impl SubscriptionJson {
118 pub(crate) const fn new(value: Value) -> Self {
119 Self(value)
120 }
121
122 pub const fn as_value(&self) -> &Value {
124 &self.0
125 }
126
127 pub fn into_value(self) -> Value {
129 self.0
130 }
131}
132
133impl fmt::Debug for SubscriptionJson {
134 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135 formatter
136 .debug_tuple("SubscriptionJson")
137 .field(&"[REDACTED]")
138 .finish()
139 }
140}
141
142#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
144pub struct SubscriptionTraffic {
145 pub upload: i64,
147 pub download: i64,
149 pub total: i64,
151 pub expire: i64,
153}
154
155#[allow(clippy::struct_excessive_bools)]
157#[derive(Clone, Default, Eq, PartialEq)]
158pub struct HappMetadata {
159 pub provider_id: Option<String>,
161 pub new_url: Option<String>,
163 pub fallback_url: Option<String>,
165 pub sub_info_color: Option<String>,
167 pub sub_info_text: Option<String>,
169 pub sub_info_button_text: Option<String>,
171 pub sub_info_button_link: Option<String>,
173 pub sub_expire_button_link: Option<String>,
175 pub tun_mode: Option<String>,
177 pub tun_type: Option<String>,
179 pub exclude_routes: Option<String>,
181 pub color_profile: Option<String>,
183 pub ping_type: Option<String>,
185 pub autoconnect_type: Option<String>,
187 pub per_app_proxy_mode: Option<String>,
189 pub per_app_proxy_list: Option<String>,
191 pub sub_expire: bool,
193 pub notification_subs_expire: bool,
195 pub no_limit_enabled: bool,
197 pub always_hwid: bool,
199 pub exclude_apns: bool,
201 pub autoconnect: bool,
203}
204
205impl HappMetadata {
206 fn from_headers(headers: &HeaderMap) -> Self {
209 Self {
210 provider_id: header_text(headers, "providerid").map(str::to_owned),
211 new_url: header_text(headers, "new-url").map(str::to_owned),
212 fallback_url: header_text(headers, "fallback-url").map(str::to_owned),
213 sub_info_color: header_text(headers, "sub-info-color").map(str::to_owned),
214 sub_info_text: header_text(headers, "sub-info-text").map(str::to_owned),
215 sub_info_button_text: header_text(headers, "sub-info-button-text").map(str::to_owned),
216 sub_info_button_link: header_text(headers, "sub-info-button-link").map(str::to_owned),
217 sub_expire_button_link: header_text(headers, "sub-expire-button-link")
218 .map(str::to_owned),
219 tun_mode: header_text(headers, "tun-mode").map(str::to_owned),
220 tun_type: header_text(headers, "tun-type").map(str::to_owned),
221 exclude_routes: header_text(headers, "exclude-routes").map(str::to_owned),
222 color_profile: header_text(headers, "color-profile").map(str::to_owned),
223 ping_type: header_text(headers, "ping-type").map(str::to_owned),
224 autoconnect_type: header_text(headers, "subscription-autoconnect-type")
225 .map(str::to_owned),
226 per_app_proxy_mode: header_text(headers, "per-app-proxy-mode").map(str::to_owned),
227 per_app_proxy_list: header_text(headers, "per-app-proxy-list").map(str::to_owned),
228 sub_expire: header_text(headers, "sub-expire") == Some("1"),
229 notification_subs_expire: header_text(headers, "notification-subs-expire") == Some("1"),
230 no_limit_enabled: header_text(headers, "no-limit-enabled") == Some("1"),
231 always_hwid: header_text(headers, "subscription-always-hwid-enable") == Some("1"),
232 exclude_apns: header_text(headers, "exclude-apns-enable") == Some("true"),
233 autoconnect: header_text(headers, "subscription-autoconnect") == Some("1"),
234 }
235 }
236}
237
238impl fmt::Debug for HappMetadata {
239 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241 formatter
242 .debug_tuple("HappMetadata")
243 .field(&"[REDACTED]")
244 .finish()
245 }
246}
247
248#[allow(clippy::struct_excessive_bools)]
250#[derive(Clone, Default, Eq, PartialEq)]
251pub struct SubscriptionMetadata {
252 pub happ: HappMetadata,
254 pub traffic: Option<SubscriptionTraffic>,
256 pub profile_title: Option<String>,
258 pub update_interval_minutes: Option<u32>,
260 pub support_url: Option<String>,
262 pub announcement: Option<String>,
264 pub routing_enabled: bool,
266 pub hide_settings: bool,
268 pub content_type: Option<String>,
270 pub content_disposition: Option<String>,
272 pub hwid_active: bool,
274 pub hwid_not_supported: bool,
276 pub hwid_limit_reached: bool,
278 pub hwid_max_devices_reached: bool,
280 profile_web_page_url: Option<String>,
281 routing_rules: Option<String>,
282}
283
284impl SubscriptionMetadata {
285 pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
288 Self {
289 happ: HappMetadata::from_headers(headers),
290 traffic: header_text(headers, "subscription-userinfo")
291 .and_then(parse_subscription_userinfo),
292 profile_title: header_text(headers, "profile-title").and_then(decode_text_header),
293 update_interval_minutes: header_text(headers, "profile-update-interval")
294 .and_then(|value| value.parse().ok()),
295 support_url: header_text(headers, "support-url").map(str::to_owned),
296 announcement: header_text(headers, "announce").and_then(decode_text_header),
297 routing_enabled: header_text(headers, "routing-enable") == Some("true"),
298 hide_settings: header_text(headers, "hide-settings") == Some("1"),
299 content_type: headers
300 .get(header::CONTENT_TYPE)
301 .and_then(|value| value.to_str().ok())
302 .map(str::to_owned),
303 content_disposition: headers
304 .get(header::CONTENT_DISPOSITION)
305 .and_then(|value| value.to_str().ok())
306 .map(str::to_owned),
307 hwid_active: header_text(headers, "x-hwid-active") == Some("true"),
308 hwid_not_supported: header_text(headers, "x-hwid-not-supported") == Some("true"),
309 hwid_limit_reached: header_text(headers, "x-hwid-limit") == Some("true"),
310 hwid_max_devices_reached: header_text(headers, "x-hwid-max-devices-reached")
311 == Some("true"),
312 profile_web_page_url: header_text(headers, "profile-web-page-url").map(str::to_owned),
313 routing_rules: header_text(headers, "routing").map(str::to_owned),
314 }
315 }
316
317 pub fn profile_web_page_url(&self) -> Option<&str> {
320 self.profile_web_page_url.as_deref()
321 }
322
323 pub fn routing_rules(&self) -> Option<&str> {
325 self.routing_rules.as_deref()
326 }
327}
328
329impl fmt::Debug for SubscriptionMetadata {
330 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
332 formatter
333 .debug_struct("SubscriptionMetadata")
334 .field("happ", &self.happ)
335 .field("traffic", &self.traffic)
336 .field("profile_title", &self.profile_title)
337 .field("update_interval_minutes", &self.update_interval_minutes)
338 .field("support_url", &self.support_url)
339 .field("announcement", &self.announcement)
340 .field("routing_enabled", &self.routing_enabled)
341 .field("hide_settings", &self.hide_settings)
342 .field("content_type", &self.content_type)
343 .field("content_disposition", &self.content_disposition)
344 .field("hwid_active", &self.hwid_active)
345 .field("hwid_not_supported", &self.hwid_not_supported)
346 .field("hwid_limit_reached", &self.hwid_limit_reached)
347 .field("hwid_max_devices_reached", &self.hwid_max_devices_reached)
348 .field("profile_web_page_url", &"[REDACTED]")
349 .field("routing_rules", &"[REDACTED]")
350 .finish()
351 }
352}
353
354#[derive(Clone, Debug, Default, PartialEq)]
356pub struct SubscriptionResponse<T> {
357 pub content: T,
359 pub metadata: SubscriptionMetadata,
361}
362
363#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
365#[serde(default, rename_all = "camelCase")]
366pub struct SubscriptionInfo {
367 #[serde(rename = "sId")]
369 pub subscription_id: String,
370 pub enabled: bool,
372 pub is_online: bool,
374 pub download: String,
376 pub upload: String,
378 pub total: String,
380 pub used: String,
382 pub remained: String,
384 pub expire: i64,
386 pub last_online: i64,
388 pub download_byte: i64,
390 pub upload_byte: i64,
392 pub total_byte: i64,
394 pub sub_url: String,
396 pub sub_json_url: String,
398 pub sub_clash_url: String,
400 pub sub_title: String,
402 pub sub_support_url: String,
404 pub sub_updates: i32,
406 pub emails: Vec<String>,
408 pub datepicker: String,
410 pub announce: String,
412}
413
414impl fmt::Debug for SubscriptionInfo {
415 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
417 formatter
418 .debug_struct("SubscriptionInfo")
419 .field("sub_updates", &self.sub_updates)
420 .field("subscription_id", &"[REDACTED]")
421 .field("enabled", &self.enabled)
422 .field("is_online", &self.is_online)
423 .field("download", &self.download)
424 .field("upload", &self.upload)
425 .field("total", &self.total)
426 .field("used", &self.used)
427 .field("remained", &self.remained)
428 .field("expire", &self.expire)
429 .field("last_online", &self.last_online)
430 .field("download_byte", &self.download_byte)
431 .field("upload_byte", &self.upload_byte)
432 .field("total_byte", &self.total_byte)
433 .field("sub_url", &"[REDACTED]")
434 .field("sub_json_url", &"[REDACTED]")
435 .field("sub_clash_url", &"[REDACTED]")
436 .field("sub_title", &self.sub_title)
437 .field("sub_support_url", &self.sub_support_url)
438 .field("emails", &"[REDACTED]")
439 .field("datepicker", &self.datepicker)
440 .field("announce", &self.announce)
441 .finish()
442 }
443}
444
445fn header_text<'headers>(headers: &'headers HeaderMap, name: &str) -> Option<&'headers str> {
446 headers.get(name)?.to_str().ok()
447}
448
449fn parse_subscription_userinfo(value: &str) -> Option<SubscriptionTraffic> {
450 let mut upload = None;
451 let mut download = None;
452 let mut total = None;
453 let mut expire = None;
454 for item in value.split(';') {
455 let (key, value) = item.trim().split_once('=')?;
456 let value = value.trim().parse().ok()?;
457 match key.trim() {
458 "upload" => upload = Some(value),
459 "download" => download = Some(value),
460 "total" => total = Some(value),
461 "expire" => expire = Some(value),
462 _ => {}
463 }
464 }
465 Some(SubscriptionTraffic {
466 upload: upload?,
467 download: download?,
468 total: total?,
469 expire: expire?,
470 })
471}
472
473fn decode_text_header(value: &str) -> Option<String> {
474 let encoded = value.strip_prefix("base64:")?;
475 let bytes = STANDARD.decode(encoded).ok()?;
476 String::from_utf8(bytes).ok()
477}
478
479#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
481pub struct HwidSlotStatus {
482 pub active: bool,
484 pub limit: i32,
486 pub registered: i32,
488 pub remaining: i32,
490 pub full: bool,
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use serde_json::json;
498
499 #[test]
500 fn device_and_documents_expose_secrets_only_explicitly() {
501 let mut device = SubscriptionDevice::new("device-hwid-secret", "test-agent");
502 device.device_os = "linux".into();
503 device.os_version = "rolling".into();
504 device.device_model = "server".into();
505 assert_eq!(device.hwid(), "device-hwid-secret");
506 assert!(!format!("{device:?}").contains("device-hwid-secret"));
507
508 let document = SubscriptionDocument::new(" first\n\n second \n".into());
509 assert_eq!(document.as_str(), " first\n\n second \n");
510 assert_eq!(document.lines().collect::<Vec<_>>(), ["first", "second"]);
511 assert!(!format!("{document:?}").contains("first"));
512 assert_eq!(document.into_string(), " first\n\n second \n");
513
514 let encoded = SubscriptionDocument::new("dmxlc3M6Ly9leGFtcGxl".into());
515 assert_eq!(encoded.decode_base64().unwrap().as_str(), "vless://example");
516 assert!(matches!(
517 SubscriptionDocument::new("%%%".into()).decode_base64(),
518 Err(SubscriptionDecodeError::Base64(_))
519 ));
520 assert!(matches!(
521 SubscriptionDocument::new("/w==".into()).decode_base64(),
522 Err(SubscriptionDecodeError::Utf8(_))
523 ));
524 }
525
526 #[test]
527 fn json_document_accessors_preserve_exact_value() {
528 let value = json!({"outbounds": [{"password": "subscription-secret"}]});
529 let document = SubscriptionJson::new(value.clone());
530 assert_eq!(document.as_value(), &value);
531 assert!(!format!("{document:?}").contains("subscription-secret"));
532 assert_eq!(document.into_value(), value);
533 }
534
535 #[test]
536 fn userinfo_parser_ignores_extensions_but_rejects_malformed_values() {
537 let parsed =
538 parse_subscription_userinfo("upload=1; download=2; total=3; expire=4; future=99")
539 .unwrap();
540 assert_eq!(parsed.upload, 1);
541 assert_eq!(parsed.download, 2);
542 assert_eq!(parsed.total, 3);
543 assert_eq!(parsed.expire, 4);
544 assert!(parse_subscription_userinfo("upload=bad").is_none());
545 assert!(parse_subscription_userinfo("upload=1; malformed").is_none());
546 assert_eq!(decode_text_header("plain"), None);
547 assert_eq!(decode_text_header("base64:%%%"), None);
548 assert_eq!(decode_text_header("base64:/w=="), None);
549 }
550}