use std::{fmt, string::FromUtf8Error};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use reqwest::header::{self, HeaderMap};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[derive(Clone, Default, Eq, PartialEq)]
pub struct SubscriptionDevice {
hwid: String,
pub user_agent: String,
pub device_os: String,
pub os_version: String,
pub device_model: String,
}
impl SubscriptionDevice {
pub fn new(hwid: impl Into<String>, user_agent: impl Into<String>) -> Self {
Self {
hwid: hwid.into(),
user_agent: user_agent.into(),
..Self::default()
}
}
pub fn hwid(&self) -> &str {
&self.hwid
}
}
impl fmt::Debug for SubscriptionDevice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SubscriptionDevice")
.field("hwid", &"[REDACTED]")
.field("user_agent", &self.user_agent)
.field("device_os", &self.device_os)
.field("os_version", &self.os_version)
.field("device_model", &self.device_model)
.finish()
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum SubscriptionDecodeError {
#[error("subscription body is not valid base64: {0}")]
Base64(#[from] base64::DecodeError),
#[error("decoded subscription body is not valid UTF-8: {0}")]
Utf8(#[from] FromUtf8Error),
}
#[derive(Clone, Default, Eq, PartialEq)]
pub struct SubscriptionDocument(String);
impl SubscriptionDocument {
pub(crate) fn new(value: String) -> Self {
Self(value)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
pub fn decode_base64(&self) -> Result<Self, SubscriptionDecodeError> {
let bytes = STANDARD.decode(self.0.trim())?;
String::from_utf8(bytes).map(Self).map_err(Into::into)
}
pub fn lines(&self) -> impl Iterator<Item = &str> {
self.0
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
}
}
impl fmt::Debug for SubscriptionDocument {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SubscriptionDocument")
.field(&"[REDACTED]")
.finish()
}
}
#[derive(Clone, Deserialize, PartialEq, Serialize)]
#[serde(transparent)]
pub struct SubscriptionJson(Value);
impl SubscriptionJson {
pub(crate) const fn new(value: Value) -> Self {
Self(value)
}
pub const fn as_value(&self) -> &Value {
&self.0
}
pub fn into_value(self) -> Value {
self.0
}
}
impl fmt::Debug for SubscriptionJson {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("SubscriptionJson")
.field(&"[REDACTED]")
.finish()
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SubscriptionTraffic {
pub upload: i64,
pub download: i64,
pub total: i64,
pub expire: i64,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Default, Eq, PartialEq)]
pub struct HappMetadata {
pub provider_id: Option<String>,
pub new_url: Option<String>,
pub fallback_url: Option<String>,
pub sub_info_color: Option<String>,
pub sub_info_text: Option<String>,
pub sub_info_button_text: Option<String>,
pub sub_info_button_link: Option<String>,
pub sub_expire_button_link: Option<String>,
pub tun_mode: Option<String>,
pub tun_type: Option<String>,
pub exclude_routes: Option<String>,
pub color_profile: Option<String>,
pub ping_type: Option<String>,
pub autoconnect_type: Option<String>,
pub per_app_proxy_mode: Option<String>,
pub per_app_proxy_list: Option<String>,
pub sub_expire: bool,
pub notification_subs_expire: bool,
pub no_limit_enabled: bool,
pub always_hwid: bool,
pub exclude_apns: bool,
pub autoconnect: bool,
}
impl HappMetadata {
fn from_headers(headers: &HeaderMap) -> Self {
Self {
provider_id: header_text(headers, "providerid").map(str::to_owned),
new_url: header_text(headers, "new-url").map(str::to_owned),
fallback_url: header_text(headers, "fallback-url").map(str::to_owned),
sub_info_color: header_text(headers, "sub-info-color").map(str::to_owned),
sub_info_text: header_text(headers, "sub-info-text").map(str::to_owned),
sub_info_button_text: header_text(headers, "sub-info-button-text").map(str::to_owned),
sub_info_button_link: header_text(headers, "sub-info-button-link").map(str::to_owned),
sub_expire_button_link: header_text(headers, "sub-expire-button-link")
.map(str::to_owned),
tun_mode: header_text(headers, "tun-mode").map(str::to_owned),
tun_type: header_text(headers, "tun-type").map(str::to_owned),
exclude_routes: header_text(headers, "exclude-routes").map(str::to_owned),
color_profile: header_text(headers, "color-profile").map(str::to_owned),
ping_type: header_text(headers, "ping-type").map(str::to_owned),
autoconnect_type: header_text(headers, "subscription-autoconnect-type")
.map(str::to_owned),
per_app_proxy_mode: header_text(headers, "per-app-proxy-mode").map(str::to_owned),
per_app_proxy_list: header_text(headers, "per-app-proxy-list").map(str::to_owned),
sub_expire: header_text(headers, "sub-expire") == Some("1"),
notification_subs_expire: header_text(headers, "notification-subs-expire") == Some("1"),
no_limit_enabled: header_text(headers, "no-limit-enabled") == Some("1"),
always_hwid: header_text(headers, "subscription-always-hwid-enable") == Some("1"),
exclude_apns: header_text(headers, "exclude-apns-enable") == Some("true"),
autoconnect: header_text(headers, "subscription-autoconnect") == Some("1"),
}
}
}
impl fmt::Debug for HappMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("HappMetadata")
.field(&"[REDACTED]")
.finish()
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Default, Eq, PartialEq)]
pub struct SubscriptionMetadata {
pub happ: HappMetadata,
pub traffic: Option<SubscriptionTraffic>,
pub profile_title: Option<String>,
pub update_interval_minutes: Option<u32>,
pub support_url: Option<String>,
pub announcement: Option<String>,
pub routing_enabled: bool,
pub hide_settings: bool,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
pub hwid_active: bool,
pub hwid_not_supported: bool,
pub hwid_limit_reached: bool,
pub hwid_max_devices_reached: bool,
profile_web_page_url: Option<String>,
routing_rules: Option<String>,
}
impl SubscriptionMetadata {
pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
Self {
happ: HappMetadata::from_headers(headers),
traffic: header_text(headers, "subscription-userinfo")
.and_then(parse_subscription_userinfo),
profile_title: header_text(headers, "profile-title").and_then(decode_text_header),
update_interval_minutes: header_text(headers, "profile-update-interval")
.and_then(|value| value.parse().ok()),
support_url: header_text(headers, "support-url").map(str::to_owned),
announcement: header_text(headers, "announce").and_then(decode_text_header),
routing_enabled: header_text(headers, "routing-enable") == Some("true"),
hide_settings: header_text(headers, "hide-settings") == Some("1"),
content_type: headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_owned),
content_disposition: headers
.get(header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.map(str::to_owned),
hwid_active: header_text(headers, "x-hwid-active") == Some("true"),
hwid_not_supported: header_text(headers, "x-hwid-not-supported") == Some("true"),
hwid_limit_reached: header_text(headers, "x-hwid-limit") == Some("true"),
hwid_max_devices_reached: header_text(headers, "x-hwid-max-devices-reached")
== Some("true"),
profile_web_page_url: header_text(headers, "profile-web-page-url").map(str::to_owned),
routing_rules: header_text(headers, "routing").map(str::to_owned),
}
}
pub fn profile_web_page_url(&self) -> Option<&str> {
self.profile_web_page_url.as_deref()
}
pub fn routing_rules(&self) -> Option<&str> {
self.routing_rules.as_deref()
}
}
impl fmt::Debug for SubscriptionMetadata {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SubscriptionMetadata")
.field("happ", &self.happ)
.field("traffic", &self.traffic)
.field("profile_title", &self.profile_title)
.field("update_interval_minutes", &self.update_interval_minutes)
.field("support_url", &self.support_url)
.field("announcement", &self.announcement)
.field("routing_enabled", &self.routing_enabled)
.field("hide_settings", &self.hide_settings)
.field("content_type", &self.content_type)
.field("content_disposition", &self.content_disposition)
.field("hwid_active", &self.hwid_active)
.field("hwid_not_supported", &self.hwid_not_supported)
.field("hwid_limit_reached", &self.hwid_limit_reached)
.field("hwid_max_devices_reached", &self.hwid_max_devices_reached)
.field("profile_web_page_url", &"[REDACTED]")
.field("routing_rules", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SubscriptionResponse<T> {
pub content: T,
pub metadata: SubscriptionMetadata,
}
#[derive(Clone, Default, Deserialize, PartialEq, Serialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SubscriptionInfo {
#[serde(rename = "sId")]
pub subscription_id: String,
pub enabled: bool,
pub is_online: bool,
pub download: String,
pub upload: String,
pub total: String,
pub used: String,
pub remained: String,
pub expire: i64,
pub last_online: i64,
pub download_byte: i64,
pub upload_byte: i64,
pub total_byte: i64,
pub sub_url: String,
pub sub_json_url: String,
pub sub_clash_url: String,
pub sub_title: String,
pub sub_support_url: String,
pub sub_updates: i32,
pub emails: Vec<String>,
pub datepicker: String,
pub announce: String,
}
impl fmt::Debug for SubscriptionInfo {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("SubscriptionInfo")
.field("sub_updates", &self.sub_updates)
.field("subscription_id", &"[REDACTED]")
.field("enabled", &self.enabled)
.field("is_online", &self.is_online)
.field("download", &self.download)
.field("upload", &self.upload)
.field("total", &self.total)
.field("used", &self.used)
.field("remained", &self.remained)
.field("expire", &self.expire)
.field("last_online", &self.last_online)
.field("download_byte", &self.download_byte)
.field("upload_byte", &self.upload_byte)
.field("total_byte", &self.total_byte)
.field("sub_url", &"[REDACTED]")
.field("sub_json_url", &"[REDACTED]")
.field("sub_clash_url", &"[REDACTED]")
.field("sub_title", &self.sub_title)
.field("sub_support_url", &self.sub_support_url)
.field("emails", &"[REDACTED]")
.field("datepicker", &self.datepicker)
.field("announce", &self.announce)
.finish()
}
}
fn header_text<'headers>(headers: &'headers HeaderMap, name: &str) -> Option<&'headers str> {
headers.get(name)?.to_str().ok()
}
fn parse_subscription_userinfo(value: &str) -> Option<SubscriptionTraffic> {
let mut upload = None;
let mut download = None;
let mut total = None;
let mut expire = None;
for item in value.split(';') {
let (key, value) = item.trim().split_once('=')?;
let value = value.trim().parse().ok()?;
match key.trim() {
"upload" => upload = Some(value),
"download" => download = Some(value),
"total" => total = Some(value),
"expire" => expire = Some(value),
_ => {}
}
}
Some(SubscriptionTraffic {
upload: upload?,
download: download?,
total: total?,
expire: expire?,
})
}
fn decode_text_header(value: &str) -> Option<String> {
let encoded = value.strip_prefix("base64:")?;
let bytes = STANDARD.decode(encoded).ok()?;
String::from_utf8(bytes).ok()
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct HwidSlotStatus {
pub active: bool,
pub limit: i32,
pub registered: i32,
pub remaining: i32,
pub full: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn device_and_documents_expose_secrets_only_explicitly() {
let mut device = SubscriptionDevice::new("device-hwid-secret", "test-agent");
device.device_os = "linux".into();
device.os_version = "rolling".into();
device.device_model = "server".into();
assert_eq!(device.hwid(), "device-hwid-secret");
assert!(!format!("{device:?}").contains("device-hwid-secret"));
let document = SubscriptionDocument::new(" first\n\n second \n".into());
assert_eq!(document.as_str(), " first\n\n second \n");
assert_eq!(document.lines().collect::<Vec<_>>(), ["first", "second"]);
assert!(!format!("{document:?}").contains("first"));
assert_eq!(document.into_string(), " first\n\n second \n");
let encoded = SubscriptionDocument::new("dmxlc3M6Ly9leGFtcGxl".into());
assert_eq!(encoded.decode_base64().unwrap().as_str(), "vless://example");
assert!(matches!(
SubscriptionDocument::new("%%%".into()).decode_base64(),
Err(SubscriptionDecodeError::Base64(_))
));
assert!(matches!(
SubscriptionDocument::new("/w==".into()).decode_base64(),
Err(SubscriptionDecodeError::Utf8(_))
));
}
#[test]
fn json_document_accessors_preserve_exact_value() {
let value = json!({"outbounds": [{"password": "subscription-secret"}]});
let document = SubscriptionJson::new(value.clone());
assert_eq!(document.as_value(), &value);
assert!(!format!("{document:?}").contains("subscription-secret"));
assert_eq!(document.into_value(), value);
}
#[test]
fn userinfo_parser_ignores_extensions_but_rejects_malformed_values() {
let parsed =
parse_subscription_userinfo("upload=1; download=2; total=3; expire=4; future=99")
.unwrap();
assert_eq!(parsed.upload, 1);
assert_eq!(parsed.download, 2);
assert_eq!(parsed.total, 3);
assert_eq!(parsed.expire, 4);
assert!(parse_subscription_userinfo("upload=bad").is_none());
assert!(parse_subscription_userinfo("upload=1; malformed").is_none());
assert_eq!(decode_text_header("plain"), None);
assert_eq!(decode_text_header("base64:%%%"), None);
assert_eq!(decode_text_header("base64:/w=="), None);
}
}