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 SubscriptionMetadata {
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 {
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("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 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("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()
}