proper productionize project
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
use reqwest::{
|
||||
Url,
|
||||
header::{REFERER, USER_AGENT},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The secret portion is encrypted as one JSON document in PostgreSQL. The
|
||||
/// host remains queryable so status pages can show where a tenant connects
|
||||
/// without ever returning its key or password.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CookieCloudSecrets {
|
||||
pub key: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CookieCloudCredentials {
|
||||
pub host: String,
|
||||
pub secrets: CookieCloudSecrets,
|
||||
}
|
||||
|
||||
impl CookieCloudCredentials {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
normalize_cookiecloud_host(&self.host)?;
|
||||
if self.secrets.key.trim().is_empty() || self.secrets.password.is_empty() {
|
||||
return Err("CookieCloud key and password are required".into());
|
||||
}
|
||||
if self.secrets.key.len() > 256 || self.secrets.password.len() > 4_096 {
|
||||
return Err("CookieCloud credentials are too long".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize a deployment-approved CookieCloud base URL. Tenant input is
|
||||
/// matched against these exact canonical values before any network request.
|
||||
pub fn normalize_cookiecloud_host(value: &str) -> Result<String, String> {
|
||||
if value.len() > 2_048 {
|
||||
return Err("CookieCloud host is too long".into());
|
||||
}
|
||||
let mut url = Url::parse(value.trim()).map_err(|_| "CookieCloud host is not a valid URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err("CookieCloud host must be an absolute http:// or https:// URL".into());
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err("CookieCloud host must not contain URL credentials".into());
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err("CookieCloud host must not contain a query or fragment".into());
|
||||
}
|
||||
let normalized_path = url.path().trim_end_matches('/').to_owned();
|
||||
url.set_path(&normalized_path);
|
||||
Ok(url.as_str().trim_end_matches('/').to_owned())
|
||||
}
|
||||
|
||||
pub fn cookiecloud_endpoint(credentials: &CookieCloudCredentials) -> Result<Url, String> {
|
||||
credentials.validate()?;
|
||||
let host = normalize_cookiecloud_host(&credentials.host)?;
|
||||
let mut endpoint = Url::parse(&host).map_err(|_| "CookieCloud host is not a valid URL")?;
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "CookieCloud host cannot be used as a base URL")?
|
||||
.pop_if_empty()
|
||||
.push("get")
|
||||
.push(credentials.secrets.key.trim());
|
||||
Ok(endpoint)
|
||||
}
|
||||
|
||||
/// Resolve only Bilibili cookies from a CookieCloud sync bucket. The returned
|
||||
/// value is kept in the listener task and is never included in status/errors.
|
||||
pub async fn fetch_bilibili_cookie(
|
||||
client: &reqwest::Client,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> Result<String, String> {
|
||||
let endpoint = cookiecloud_endpoint(credentials)?;
|
||||
let response = client
|
||||
.post(endpoint)
|
||||
.form(&[("password", credentials.secrets.password.as_str())])
|
||||
.header(REFERER, "https://live.bilibili.com/")
|
||||
.header(USER_AGENT, "Mozilla/5.0 lxc-streamutils/2.0")
|
||||
.send()
|
||||
.await
|
||||
// reqwest errors can include the full URL, whose path contains the
|
||||
// CookieCloud key. Keep that bearer-like value out of logs/responses.
|
||||
.map_err(|_| "CookieCloud network request failed".to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("CookieCloud returned HTTP {}", response.status()));
|
||||
}
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "CookieCloud response was not valid JSON".to_string())?;
|
||||
cookie_header(&value)
|
||||
}
|
||||
|
||||
fn cookie_header(value: &Value) -> Result<String, String> {
|
||||
let mut cookies = Vec::new();
|
||||
if let Some(domains) = value.get("cookie_data").and_then(Value::as_object) {
|
||||
for (domain, stored) in domains {
|
||||
if !domain.contains("bilibili.com") {
|
||||
continue;
|
||||
}
|
||||
let entries: Vec<&Value> = if let Some(array) = stored.as_array() {
|
||||
array.iter().collect()
|
||||
} else {
|
||||
stored
|
||||
.as_object()
|
||||
.map(|values| values.values().collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for item in entries {
|
||||
let Some(name) = item.get("name").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = item.get("value").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
// Cookie names cannot contain these delimiters. Ignore malformed
|
||||
// upstream entries instead of allowing header injection.
|
||||
if name.is_empty()
|
||||
|| name.contains([';', '=', '\r', '\n'])
|
||||
|| value.contains([';', '\r', '\n'])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
cookies.push(format!("{name}={value}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if cookies.iter().any(|cookie| cookie.starts_with("SESSDATA=")) {
|
||||
Ok(cookies.join("; "))
|
||||
} else {
|
||||
Err("CookieCloud does not contain a Bilibili SESSDATA cookie".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_only_bilibili_cookie_data() {
|
||||
let value = json!({"cookie_data": {
|
||||
".bilibili.com": [
|
||||
{"name":"SESSDATA","value":"session"},
|
||||
{"name":"bili_jct","value":"csrf"}
|
||||
],
|
||||
"example.com": [{"name":"secret","value":"must-not-leak"}]
|
||||
}});
|
||||
let result = cookie_header(&value).unwrap();
|
||||
assert!(result.contains("SESSDATA=session"));
|
||||
assert!(result.contains("bili_jct=csrf"));
|
||||
assert!(!result.contains("must-not-leak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_authenticated_cookie() {
|
||||
let value = json!({"cookie_data": {"bilibili.com": [
|
||||
{"name":"buvid3","value":"anonymous"}
|
||||
]}});
|
||||
assert!(cookie_header(&value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cookiecloud_key_is_encoded_as_one_path_segment() {
|
||||
let credentials = CookieCloudCredentials {
|
||||
host: "https://cookies.example.test/base/".into(),
|
||||
secrets: CookieCloudSecrets {
|
||||
key: "bucket/../../admin?x=1".into(),
|
||||
password: "secret".into(),
|
||||
},
|
||||
};
|
||||
let endpoint = cookiecloud_endpoint(&credentials).unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://cookies.example.test/base/get/bucket%2F..%2F..%2Fadmin%3Fx=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_host_rejects_embedded_credentials_and_queries() {
|
||||
assert!(normalize_cookiecloud_host("https://user:pass@example.test").is_err());
|
||||
assert!(normalize_cookiecloud_host("https://example.test/?next=internal").is_err());
|
||||
assert_eq!(
|
||||
normalize_cookiecloud_host("https://example.test/base/").unwrap(),
|
||||
"https://example.test/base"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user