mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-26 14:58:55 +08:00
chore: cleanup legacy logic (#15072)
This commit is contained in:
@@ -9,6 +9,7 @@ pub mod file_type;
|
||||
pub mod hashcash;
|
||||
pub mod html_sanitize;
|
||||
pub mod image;
|
||||
pub mod license;
|
||||
pub mod llm;
|
||||
pub mod permission;
|
||||
pub mod safe_fetch;
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result as AnyResult, bail};
|
||||
use napi::{Env, Error, Result, Status, Task, bindgen_prelude::AsyncTask};
|
||||
use napi_derive::napi;
|
||||
use serde::de::DeserializeOwned;
|
||||
use url::Url;
|
||||
|
||||
const AFFINE_PRO_ENDPOINT: &str = "https://app.affine.pro";
|
||||
const AFFINE_PRO_HOST: &str = "app.affine.pro";
|
||||
const AFFINE_PRO_REQUEST_TIMEOUT_MS: u32 = 10_000;
|
||||
const AFFINE_PRO_MAX_BYTES: u32 = 1024 * 1024;
|
||||
const ECH_DNS_QUERY_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
static AFFINE_PRO_ECH_CONFIG: OnceLock<Mutex<Option<Vec<u8>>>> = OnceLock::new();
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseKeyRequest {
|
||||
pub license_key: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseHealthRequest {
|
||||
pub license_key: String,
|
||||
pub validate_key: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseRecurringRequest {
|
||||
pub license_key: String,
|
||||
pub recurring: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseSeatsRequest {
|
||||
pub license_key: String,
|
||||
pub seats: u32,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseInfo {
|
||||
pub plan: String,
|
||||
pub recurring: String,
|
||||
pub quantity: u32,
|
||||
pub expires_at: f64,
|
||||
pub validate_key: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseError {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct LicenseResponse {
|
||||
pub license: Option<LicenseInfo>,
|
||||
pub error: Option<LicenseError>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CommandResponse {
|
||||
pub error: Option<LicenseError>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PortalResponse {
|
||||
pub url: Option<String>,
|
||||
pub error: Option<LicenseError>,
|
||||
}
|
||||
|
||||
pub struct AsyncActivateLicenseTask {
|
||||
request: LicenseKeyRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncDeactivateLicenseTask {
|
||||
request: LicenseKeyRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncCheckLicenseHealthTask {
|
||||
request: LicenseHealthRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncUpdateLicenseRecurringTask {
|
||||
request: LicenseRecurringRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncUpdateLicenseSeatsTask {
|
||||
request: LicenseSeatsRequest,
|
||||
}
|
||||
|
||||
pub struct AsyncCreateCustomerPortalTask {
|
||||
request: LicenseKeyRequest,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncActivateLicenseTask {
|
||||
type Output = LicenseResponse;
|
||||
type JsValue = LicenseResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
license_info(
|
||||
&format!("/api/team/licenses/{}/activate", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Post,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn activate_license(request: LicenseKeyRequest) -> AsyncTask<AsyncActivateLicenseTask> {
|
||||
AsyncTask::new(AsyncActivateLicenseTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncDeactivateLicenseTask {
|
||||
type Output = CommandResponse;
|
||||
type JsValue = CommandResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
command(
|
||||
&format!("/api/team/licenses/{}/deactivate", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Post,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn deactivate_license(request: LicenseKeyRequest) -> AsyncTask<AsyncDeactivateLicenseTask> {
|
||||
AsyncTask::new(AsyncDeactivateLicenseTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncCheckLicenseHealthTask {
|
||||
type Output = LicenseResponse;
|
||||
type JsValue = LicenseResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
license_info(
|
||||
&format!("/api/team/licenses/{}/health", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Get,
|
||||
Some(HashMap::from([(
|
||||
"x-validate-key".to_string(),
|
||||
self.request.validate_key.clone(),
|
||||
)])),
|
||||
None,
|
||||
)
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn check_license_health(request: LicenseHealthRequest) -> AsyncTask<AsyncCheckLicenseHealthTask> {
|
||||
AsyncTask::new(AsyncCheckLicenseHealthTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncUpdateLicenseRecurringTask {
|
||||
type Output = CommandResponse;
|
||||
type JsValue = CommandResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"recurring": self.request.recurring,
|
||||
}))
|
||||
.map_err(invalid_arg)?;
|
||||
command(
|
||||
&format!("/api/team/licenses/{}/recurring", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Post,
|
||||
None,
|
||||
Some(body),
|
||||
)
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn update_license_recurring(request: LicenseRecurringRequest) -> AsyncTask<AsyncUpdateLicenseRecurringTask> {
|
||||
AsyncTask::new(AsyncUpdateLicenseRecurringTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncUpdateLicenseSeatsTask {
|
||||
type Output = CommandResponse;
|
||||
type JsValue = CommandResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
let body = serde_json::to_vec(&serde_json::json!({
|
||||
"seats": self.request.seats,
|
||||
}))
|
||||
.map_err(invalid_arg)?;
|
||||
command(
|
||||
&format!("/api/team/licenses/{}/seats", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Post,
|
||||
None,
|
||||
Some(body),
|
||||
)
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn update_license_seats(request: LicenseSeatsRequest) -> AsyncTask<AsyncUpdateLicenseSeatsTask> {
|
||||
AsyncTask::new(AsyncUpdateLicenseSeatsTask { request })
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncCreateCustomerPortalTask {
|
||||
type Output = PortalResponse;
|
||||
type JsValue = PortalResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
let response = match affine_pro_request(
|
||||
&format!("/api/team/licenses/{}/create-customer-portal", self.request.license_key),
|
||||
safefetch::SafeFetchMethod::Post,
|
||||
None,
|
||||
None,
|
||||
) {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return Ok(PortalResponse {
|
||||
url: None,
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
};
|
||||
if let Some(error) = affine_pro_error(&response) {
|
||||
return Ok(PortalResponse {
|
||||
url: None,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
let body: PortalPayload = match parse_body(&response) {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return Ok(PortalResponse {
|
||||
url: None,
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
};
|
||||
if body.url.is_empty() {
|
||||
return Ok(PortalResponse {
|
||||
url: None,
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
Ok(PortalResponse {
|
||||
url: Some(body.url),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn create_license_customer_portal(request: LicenseKeyRequest) -> AsyncTask<AsyncCreateCustomerPortalTask> {
|
||||
AsyncTask::new(AsyncCreateCustomerPortalTask { request })
|
||||
}
|
||||
|
||||
fn license_info(
|
||||
path: &str,
|
||||
method: safefetch::SafeFetchMethod,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> AnyResult<LicenseResponse> {
|
||||
let response = match affine_pro_request(path, method, headers, body) {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return Ok(LicenseResponse {
|
||||
license: None,
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
};
|
||||
if let Some(error) = affine_pro_error(&response) {
|
||||
return Ok(LicenseResponse {
|
||||
license: None,
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
let license = match parse_license_info(&response) {
|
||||
Ok(license) => license,
|
||||
Err(error) if error.to_string() == "license_expired" => {
|
||||
return Ok(LicenseResponse {
|
||||
license: None,
|
||||
error: Some(license_expired_error()),
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(LicenseResponse {
|
||||
license: None,
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(LicenseResponse {
|
||||
license: Some(license),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn command(
|
||||
path: &str,
|
||||
method: safefetch::SafeFetchMethod,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> AnyResult<CommandResponse> {
|
||||
let response = match affine_pro_request(path, method, headers, body) {
|
||||
Ok(response) => response,
|
||||
Err(_) => {
|
||||
return Ok(CommandResponse {
|
||||
error: Some(internal_affine_pro_error()),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(CommandResponse {
|
||||
error: affine_pro_error(&response),
|
||||
})
|
||||
}
|
||||
|
||||
fn affine_pro_request(
|
||||
path: &str,
|
||||
method: safefetch::SafeFetchMethod,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<Vec<u8>>,
|
||||
) -> AnyResult<safefetch::SafeFetchResponse> {
|
||||
let url = Url::parse(AFFINE_PRO_ENDPOINT)
|
||||
.context("invalid affine pro endpoint")?
|
||||
.join(path)
|
||||
.context("invalid affine pro path")?;
|
||||
let mut headers = headers.unwrap_or_default();
|
||||
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
||||
|
||||
safefetch::safe_fetch(&safefetch::SafeFetchRequest {
|
||||
url: url.to_string(),
|
||||
method: Some(method),
|
||||
headers: Some(headers),
|
||||
body,
|
||||
timeout_ms: Some(AFFINE_PRO_REQUEST_TIMEOUT_MS),
|
||||
max_redirects: Some(3),
|
||||
max_bytes: Some(AFFINE_PRO_MAX_BYTES),
|
||||
allowed_headers: Some(vec![
|
||||
"authorization".to_string(),
|
||||
"content-type".to_string(),
|
||||
"x-validate-key".to_string(),
|
||||
]),
|
||||
allowed_hosts: Some(vec![AFFINE_PRO_HOST.to_string()]),
|
||||
allow_http: Some(false),
|
||||
allow_private_target_origin: None,
|
||||
ech_config_list: Some(affine_pro_ech_config()?),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_license_info(response: &safefetch::SafeFetchResponse) -> AnyResult<LicenseInfo> {
|
||||
let body: LicensePayload = parse_body(response)?;
|
||||
let expires_at = parse_future_end_at(&body.end_at)?;
|
||||
Ok(LicenseInfo {
|
||||
plan: body.plan,
|
||||
recurring: body.recurring,
|
||||
quantity: body.quantity,
|
||||
expires_at,
|
||||
validate_key: response.headers.get("x-next-validate-key").cloned().unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn affine_pro_error(response: &safefetch::SafeFetchResponse) -> Option<LicenseError> {
|
||||
if (200..300).contains(&response.status) {
|
||||
return None;
|
||||
}
|
||||
let body = String::from_utf8_lossy(&response.body).to_string();
|
||||
if serde_json::from_str::<serde_json::Value>(&body).is_err() {
|
||||
return Some(internal_affine_pro_error());
|
||||
}
|
||||
Some(LicenseError {
|
||||
status: response.status,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn internal_affine_pro_error() -> LicenseError {
|
||||
LicenseError {
|
||||
status: 500,
|
||||
body: serde_json::json!({
|
||||
"status": 500,
|
||||
"type": "internal_server_error",
|
||||
"name": "internal_server_error",
|
||||
"message": "Failed to contact with https://app.affine.pro",
|
||||
"data": null,
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn license_expired_error() -> LicenseError {
|
||||
LicenseError {
|
||||
status: 400,
|
||||
body: serde_json::json!({
|
||||
"status": 400,
|
||||
"type": "bad_request",
|
||||
"name": "license_expired",
|
||||
"message": "License has expired.",
|
||||
"data": null,
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_body<T: DeserializeOwned>(response: &safefetch::SafeFetchResponse) -> AnyResult<T> {
|
||||
serde_json::from_slice(&response.body).context("invalid affine pro response")
|
||||
}
|
||||
|
||||
fn parse_future_end_at(value: &serde_json::Value) -> AnyResult<f64> {
|
||||
let millis = match value {
|
||||
serde_json::Value::Number(number) => number.as_f64().context("invalid license expiration")?,
|
||||
serde_json::Value::String(value) => value
|
||||
.parse::<f64>()
|
||||
.or_else(|_| chrono::DateTime::parse_from_rfc3339(value).map(|date| date.timestamp_millis() as f64))
|
||||
.context("invalid license expiration")?,
|
||||
_ => bail!("invalid license expiration"),
|
||||
};
|
||||
if !millis.is_finite() || millis <= now_millis() {
|
||||
bail!("license_expired");
|
||||
}
|
||||
Ok(millis)
|
||||
}
|
||||
|
||||
fn now_millis() -> f64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as f64
|
||||
}
|
||||
|
||||
fn affine_pro_ech_config() -> AnyResult<Vec<u8>> {
|
||||
let cache = AFFINE_PRO_ECH_CONFIG.get_or_init(|| Mutex::new(None));
|
||||
{
|
||||
let cached = cache.lock().map_err(|_| anyhow::anyhow!("ech cache poisoned"))?;
|
||||
if let Some(config) = cached.as_ref() {
|
||||
return Ok(config.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let config = safefetch::ech::cloudflare_https_ech_config_list(
|
||||
AFFINE_PRO_HOST,
|
||||
Duration::from_millis(ECH_DNS_QUERY_TIMEOUT_MS as u64),
|
||||
)?;
|
||||
let mut cached = cache.lock().map_err(|_| anyhow::anyhow!("ech cache poisoned"))?;
|
||||
*cached = Some(config.clone());
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn invalid_arg(error: impl ToString) -> Error {
|
||||
Error::new(Status::InvalidArg, error.to_string())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LicensePayload {
|
||||
plan: String,
|
||||
recurring: String,
|
||||
quantity: u32,
|
||||
#[serde(rename = "endAt")]
|
||||
end_at: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PortalPayload {
|
||||
url: String,
|
||||
}
|
||||
@@ -1,46 +1,37 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{Cursor, Read},
|
||||
net::{IpAddr, SocketAddr, ToSocketAddrs},
|
||||
time::Duration,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ::image::{ImageFormat, ImageReader};
|
||||
use anyhow::{Context, Result as AnyResult, bail};
|
||||
use napi::{
|
||||
Env, Error, Result, Status, Task,
|
||||
bindgen_prelude::{AsyncTask, Buffer},
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use reqwest::{
|
||||
Method,
|
||||
blocking::{Client, Response},
|
||||
header::{HeaderMap, HeaderName, HeaderValue, LOCATION},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u32 = 10_000;
|
||||
const DEFAULT_MAX_REDIRECTS: u32 = 3;
|
||||
const DEFAULT_MAX_BYTES: u32 = 10 * 1024 * 1024;
|
||||
const MAX_IMAGE_DIMENSION: u32 = 16_384;
|
||||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum SafeFetchMethod {
|
||||
Get,
|
||||
Head,
|
||||
Post,
|
||||
Put,
|
||||
Propfind,
|
||||
Report,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SafeFetchRequest {
|
||||
pub url: String,
|
||||
pub method: Option<SafeFetchMethod>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
pub body: Option<Buffer>,
|
||||
pub timeout_ms: Option<u32>,
|
||||
pub max_redirects: Option<u32>,
|
||||
pub max_bytes: Option<u32>,
|
||||
pub allowed_headers: Option<Vec<String>>,
|
||||
pub allowed_hosts: Option<Vec<String>>,
|
||||
pub allow_http: Option<bool>,
|
||||
pub allow_private_target_origin: Option<bool>,
|
||||
pub enable_ech: Option<bool>,
|
||||
pub ech_config_list: Option<Buffer>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
@@ -111,36 +102,27 @@ pub struct AsyncRemoteMimeTypeTask {
|
||||
request: RemoteMimeTypeRequest,
|
||||
}
|
||||
|
||||
pub struct SafeFetchOutput {
|
||||
status: u16,
|
||||
final_url: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
struct SafeFetchParams {
|
||||
url: String,
|
||||
method: Option<SafeFetchMethod>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
timeout_ms: Option<u32>,
|
||||
max_redirects: Option<u32>,
|
||||
max_bytes: Option<u32>,
|
||||
allow_private_origins: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub struct RemoteAttachmentFetchOutput {
|
||||
final_url: String,
|
||||
mime_type: String,
|
||||
body: Vec<u8>,
|
||||
impl From<SafeFetchMethod> for safefetch::SafeFetchMethod {
|
||||
fn from(method: SafeFetchMethod) -> Self {
|
||||
match method {
|
||||
SafeFetchMethod::Get => safefetch::SafeFetchMethod::Get,
|
||||
SafeFetchMethod::Head => safefetch::SafeFetchMethod::Head,
|
||||
SafeFetchMethod::Post => safefetch::SafeFetchMethod::Post,
|
||||
SafeFetchMethod::Put => safefetch::SafeFetchMethod::Put,
|
||||
SafeFetchMethod::Propfind => safefetch::SafeFetchMethod::Propfind,
|
||||
SafeFetchMethod::Report => safefetch::SafeFetchMethod::Report,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncSafeFetchTask {
|
||||
type Output = SafeFetchOutput;
|
||||
type Output = safefetch::SafeFetchResponse;
|
||||
type JsValue = SafeFetchResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
safe_fetch_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
let request = safe_fetch_request(&self.request).map_err(invalid_arg)?;
|
||||
safefetch::safe_fetch(&request).map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
@@ -160,29 +142,44 @@ pub fn safe_fetch(request: SafeFetchRequest) -> AsyncTask<AsyncSafeFetchTask> {
|
||||
|
||||
#[napi]
|
||||
pub fn assert_safe_url(request: AssertSafeUrlRequest) -> Result<()> {
|
||||
assert_safe_url_inner(&request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
safefetch::assert_safe_url(&request.url).map_err(invalid_arg)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn inspect_image_for_proxy(input: Buffer, options: Option<ImageInspectionOptions>) -> Result<ImageInspection> {
|
||||
inspect_image_for_proxy_inner(
|
||||
let output = safefetch::inspect_image(
|
||||
&input,
|
||||
options.unwrap_or(ImageInspectionOptions {
|
||||
image_inspection_options(options.unwrap_or(ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
}),
|
||||
})),
|
||||
)
|
||||
.map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
.map_err(invalid_arg)?;
|
||||
Ok(ImageInspection {
|
||||
mime_type: output.mime_type,
|
||||
width: output.width,
|
||||
height: output.height,
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl Task for AsyncRemoteAttachmentFetchTask {
|
||||
type Output = RemoteAttachmentFetchOutput;
|
||||
type Output = safefetch::RemoteAttachmentFetchResponse;
|
||||
type JsValue = RemoteAttachmentFetchResponse;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
fetch_remote_attachment_inner(&self.request).map_err(|error| Error::new(Status::InvalidArg, error.to_string()))
|
||||
safefetch::fetch_remote_attachment(&safefetch::RemoteAttachmentFetchRequest {
|
||||
url: self.request.url.clone(),
|
||||
timeout_ms: self.request.timeout_ms,
|
||||
max_bytes: self.request.max_bytes,
|
||||
allow_private_target_origin: self.request.allow_private_target_origin,
|
||||
expected_content_type_prefix: self.request.expected_content_type_prefix.clone(),
|
||||
max_image_width: self.request.max_image_width,
|
||||
max_image_height: self.request.max_image_height,
|
||||
max_image_pixels: self.request.max_image_pixels,
|
||||
})
|
||||
.map_err(invalid_arg)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
@@ -205,7 +202,10 @@ impl Task for AsyncRemoteMimeTypeTask {
|
||||
type JsValue = String;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
Ok(infer_remote_mime_type_inner(&self.request))
|
||||
Ok(safefetch::infer_remote_mime_type(&safefetch::RemoteMimeTypeRequest {
|
||||
url: self.request.url.clone(),
|
||||
timeout_ms: self.request.timeout_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
@@ -218,472 +218,41 @@ pub fn infer_remote_mime_type(request: RemoteMimeTypeRequest) -> AsyncTask<Async
|
||||
AsyncTask::new(AsyncRemoteMimeTypeTask { request })
|
||||
}
|
||||
|
||||
fn safe_fetch_inner(request: &SafeFetchRequest) -> AnyResult<SafeFetchOutput> {
|
||||
safe_fetch_params_inner(&SafeFetchParams {
|
||||
pub(crate) fn safe_fetch_request(request: &SafeFetchRequest) -> anyhow::Result<safefetch::SafeFetchRequest> {
|
||||
Ok(safefetch::SafeFetchRequest {
|
||||
url: request.url.clone(),
|
||||
method: request.method,
|
||||
method: request.method.map(Into::into),
|
||||
headers: request.headers.clone(),
|
||||
body: request.body.as_ref().map(|body| body.to_vec()),
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: request.max_redirects,
|
||||
max_bytes: request.max_bytes,
|
||||
allow_private_origins: None,
|
||||
allowed_headers: request.allowed_headers.clone(),
|
||||
allowed_hosts: request.allowed_hosts.clone(),
|
||||
allow_http: request.allow_http,
|
||||
allow_private_target_origin: request.allow_private_target_origin,
|
||||
ech_config_list: ech_config_list(request)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_fetch_params_inner(request: &SafeFetchParams) -> AnyResult<SafeFetchOutput> {
|
||||
let timeout = Duration::from_millis(u64::from(request.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)));
|
||||
let max_redirects = request.max_redirects.unwrap_or(DEFAULT_MAX_REDIRECTS);
|
||||
let max_bytes = usize::try_from(request.max_bytes.unwrap_or(DEFAULT_MAX_BYTES)).context("invalid maxBytes")?;
|
||||
let method = request.method.unwrap_or(SafeFetchMethod::Get);
|
||||
let mut current = parse_safe_url(&request.url)?;
|
||||
let headers = build_headers(request.headers.as_ref())?;
|
||||
|
||||
for redirect_count in 0..=max_redirects {
|
||||
let addrs = resolve_safe_socket_addrs(¤t, request.allow_private_origins.as_deref())?;
|
||||
let client = build_pinned_client(¤t, &addrs, timeout)?;
|
||||
let response = send_request(&client, method, current.clone(), headers.clone())?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count >= max_redirects {
|
||||
bail!("too_many_redirects");
|
||||
}
|
||||
let Some(location) = response.headers().get(LOCATION) else {
|
||||
return response_to_output(response, current, max_bytes, method);
|
||||
};
|
||||
let location = location.to_str().context("invalid redirect location")?;
|
||||
current = parse_safe_url(current.join(location).context("invalid redirect location")?.as_str())?;
|
||||
continue;
|
||||
}
|
||||
|
||||
return response_to_output(response, current, max_bytes, method);
|
||||
fn image_inspection_options(options: ImageInspectionOptions) -> safefetch::ImageInspectionOptions {
|
||||
safefetch::ImageInspectionOptions {
|
||||
max_width: options.max_width,
|
||||
max_height: options.max_height,
|
||||
max_pixels: options.max_pixels,
|
||||
}
|
||||
|
||||
bail!("too_many_redirects")
|
||||
}
|
||||
|
||||
fn fetch_remote_attachment_inner(request: &RemoteAttachmentFetchRequest) -> AnyResult<RemoteAttachmentFetchOutput> {
|
||||
let allow_private_origins =
|
||||
private_target_origin_allowlist(&request.url, request.allow_private_target_origin.unwrap_or(false))?;
|
||||
let response = safe_fetch_params_inner(&SafeFetchParams {
|
||||
url: request.url.clone(),
|
||||
method: Some(SafeFetchMethod::Get),
|
||||
headers: None,
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
|
||||
max_bytes: Some(request.max_bytes),
|
||||
allow_private_origins,
|
||||
})?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
bail!("fetch_failed_status: {}", response.status);
|
||||
}
|
||||
let mime_type = normalize_mime_type(response.headers.get("content-type"));
|
||||
if let Some(expected) = request.expected_content_type_prefix.as_deref() {
|
||||
if !mime_type.starts_with(expected) {
|
||||
bail!("content_type_mismatch");
|
||||
}
|
||||
if expected.starts_with("image/") {
|
||||
inspect_image_for_proxy_inner(
|
||||
&response.body,
|
||||
ImageInspectionOptions {
|
||||
max_width: request.max_image_width,
|
||||
max_height: request.max_image_height,
|
||||
max_pixels: request.max_image_pixels,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RemoteAttachmentFetchOutput {
|
||||
final_url: response.final_url,
|
||||
mime_type,
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn infer_remote_mime_type_inner(request: &RemoteMimeTypeRequest) -> String {
|
||||
let Ok(url) = Url::parse(&request.url) else {
|
||||
return "application/octet-stream".to_string();
|
||||
};
|
||||
if let Some(mime_type) = infer_mime_type_from_extension(&url) {
|
||||
return mime_type.to_string();
|
||||
}
|
||||
let Ok(response) = safe_fetch_params_inner(&SafeFetchParams {
|
||||
url: request.url.clone(),
|
||||
method: Some(SafeFetchMethod::Head),
|
||||
headers: None,
|
||||
timeout_ms: request.timeout_ms,
|
||||
max_redirects: Some(DEFAULT_MAX_REDIRECTS),
|
||||
max_bytes: Some(0),
|
||||
allow_private_origins: None,
|
||||
}) else {
|
||||
return "application/octet-stream".to_string();
|
||||
};
|
||||
normalize_mime_type(response.headers.get("content-type"))
|
||||
}
|
||||
|
||||
fn private_target_origin_allowlist(raw_url: &str, allow_private_target_origin: bool) -> AnyResult<Option<Vec<String>>> {
|
||||
if !allow_private_target_origin {
|
||||
fn ech_config_list(request: &SafeFetchRequest) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
if !request.enable_ech.unwrap_or(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(vec![parse_safe_url(raw_url)?.origin().ascii_serialization()]))
|
||||
}
|
||||
|
||||
fn normalize_mime_type(value: Option<&String>) -> String {
|
||||
value
|
||||
.and_then(|value| value.split(';').next())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn infer_mime_type_from_extension(url: &Url) -> Option<&'static str> {
|
||||
let extension = url.path_segments()?.next_back()?.rsplit_once('.')?.1;
|
||||
match extension.to_ascii_lowercase().as_str() {
|
||||
"pdf" => Some("application/pdf"),
|
||||
"mp3" => Some("audio/mpeg"),
|
||||
"opus" => Some("audio/opus"),
|
||||
"ogg" => Some("audio/ogg"),
|
||||
"aac" => Some("audio/aac"),
|
||||
"m4a" => Some("audio/aac"),
|
||||
"flac" => Some("audio/flac"),
|
||||
"ogv" => Some("video/ogg"),
|
||||
"wav" => Some("audio/wav"),
|
||||
"png" => Some("image/png"),
|
||||
"jpeg" | "jpg" => Some("image/jpeg"),
|
||||
"webp" => Some("image/webp"),
|
||||
"txt" | "md" => Some("text/plain"),
|
||||
"mov" => Some("video/mov"),
|
||||
"mpeg" => Some("video/mpeg"),
|
||||
"mp4" => Some("video/mp4"),
|
||||
"avi" => Some("video/avi"),
|
||||
"wmv" => Some("video/wmv"),
|
||||
"flv" => Some("video/flv"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_safe_url_inner(request: &AssertSafeUrlRequest) -> AnyResult<()> {
|
||||
let url = parse_safe_url(&request.url)?;
|
||||
resolve_safe_socket_addrs(&url, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_safe_url(raw: &str) -> AnyResult<Url> {
|
||||
let url = Url::parse(raw).context("invalid_url")?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
_ => bail!("disallowed_protocol"),
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
bail!("url_has_credentials");
|
||||
}
|
||||
if url.host_str().is_none() {
|
||||
bail!("blocked_hostname");
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn resolve_safe_socket_addrs(url: &Url, allow_private_origins: Option<&[String]>) -> AnyResult<Vec<SocketAddr>> {
|
||||
let host = url.host_str().context("blocked_hostname")?;
|
||||
let port = url.port_or_known_default().context("blocked_hostname")?;
|
||||
let origin = url.origin().ascii_serialization();
|
||||
let allow_private = allow_private_origins
|
||||
.map(|origins| origins.iter().any(|allowed| allowed == &origin))
|
||||
.unwrap_or(false);
|
||||
let addrs: Vec<SocketAddr> = (host, port)
|
||||
.to_socket_addrs()
|
||||
.context("unresolvable_hostname")?
|
||||
.collect();
|
||||
if addrs.is_empty() {
|
||||
bail!("unresolvable_hostname");
|
||||
}
|
||||
for addr in &addrs {
|
||||
if is_blocked_ip(addr.ip()) && !allow_private {
|
||||
bail!("blocked_ip");
|
||||
}
|
||||
}
|
||||
Ok(addrs)
|
||||
}
|
||||
|
||||
fn build_pinned_client(url: &Url, addrs: &[SocketAddr], timeout: Duration) -> AnyResult<Client> {
|
||||
let host = url.host_str().context("blocked_hostname")?;
|
||||
Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.tls_backend_preconfigured(webpki_tls_config()?)
|
||||
.resolve_to_addrs(host, addrs)
|
||||
.build()
|
||||
.context("failed to build http client")
|
||||
}
|
||||
|
||||
fn webpki_tls_config() -> AnyResult<rustls::ClientConfig> {
|
||||
let root_store = rustls::RootCertStore {
|
||||
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
|
||||
let Some(config_list) = request.ech_config_list.as_ref() else {
|
||||
anyhow::bail!("ech_config_required");
|
||||
};
|
||||
Ok(
|
||||
rustls::ClientConfig::builder_with_provider(rustls::crypto::aws_lc_rs::default_provider().into())
|
||||
.with_safe_default_protocol_versions()
|
||||
.context("failed to build tls protocol config")?
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth(),
|
||||
)
|
||||
Ok(Some(config_list.to_vec()))
|
||||
}
|
||||
|
||||
fn build_headers(headers: Option<&HashMap<String, String>>) -> AnyResult<HeaderMap> {
|
||||
let mut out = HeaderMap::new();
|
||||
let Some(headers) = headers else {
|
||||
return Ok(out);
|
||||
};
|
||||
for (name, value) in headers {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if !(lower.starts_with("sec-") || lower.starts_with("accept") || lower == "user-agent") {
|
||||
continue;
|
||||
}
|
||||
out.insert(
|
||||
HeaderName::from_bytes(name.as_bytes()).context("invalid header name")?,
|
||||
HeaderValue::from_str(value).context("invalid header value")?,
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn send_request(client: &Client, method: SafeFetchMethod, url: Url, headers: HeaderMap) -> AnyResult<Response> {
|
||||
let method = match method {
|
||||
SafeFetchMethod::Get => Method::GET,
|
||||
SafeFetchMethod::Head => Method::HEAD,
|
||||
};
|
||||
client
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.context("failed to fetch url")
|
||||
}
|
||||
|
||||
fn response_to_output(
|
||||
mut response: Response,
|
||||
url: Url,
|
||||
max_bytes: usize,
|
||||
method: SafeFetchMethod,
|
||||
) -> AnyResult<SafeFetchOutput> {
|
||||
let status = response.status().as_u16();
|
||||
let headers = response_headers(response.headers());
|
||||
if matches!(method, SafeFetchMethod::Head) {
|
||||
return Ok(SafeFetchOutput {
|
||||
status,
|
||||
final_url: url.to_string(),
|
||||
headers,
|
||||
body: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
if let Some(len) = response.content_length()
|
||||
&& len > max_bytes as u64
|
||||
{
|
||||
bail!("response_too_large");
|
||||
}
|
||||
response
|
||||
.by_ref()
|
||||
.take(u64::try_from(max_bytes).unwrap_or(u64::MAX) + 1)
|
||||
.read_to_end(&mut body)
|
||||
.context("failed to read response")?;
|
||||
if body.len() > max_bytes {
|
||||
bail!("response_too_large");
|
||||
}
|
||||
Ok(SafeFetchOutput {
|
||||
status,
|
||||
final_url: url.to_string(),
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers(headers: &HeaderMap) -> HashMap<String, String> {
|
||||
headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|value| (name.as_str().to_string(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_blocked_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(ip) => {
|
||||
ip.is_private()
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_link_local()
|
||||
|| ip.is_broadcast()
|
||||
|| ip.is_multicast()
|
||||
|| ip.is_documentation()
|
||||
|| ip.octets()[0] == 0
|
||||
|| ip.octets()[0] >= 224
|
||||
|| ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])
|
||||
|| ip.octets()[0] == 169 && ip.octets()[1] == 254
|
||||
|| ip.octets()[0] == 198 && (18..=19).contains(&ip.octets()[1])
|
||||
|| ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return is_blocked_ip(IpAddr::V4(v4));
|
||||
}
|
||||
if let Some(v4) = extract_6to4_ipv4(ip).or_else(|| extract_teredo_client_ipv4(ip)) {
|
||||
return is_blocked_ip(IpAddr::V4(v4));
|
||||
}
|
||||
(ip.segments()[0] & 0xe000 != 0x2000)
|
||||
|| ip.is_loopback()
|
||||
|| ip.is_unspecified()
|
||||
|| ip.is_multicast()
|
||||
|| (ip.segments()[0] & 0xfe00 == 0xfc00)
|
||||
|| (ip.segments()[0] & 0xffc0 == 0xfe80)
|
||||
|| (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_6to4_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
let segments = ip.segments();
|
||||
if segments[0] != 0x2002 {
|
||||
return None;
|
||||
}
|
||||
Some(std::net::Ipv4Addr::new(
|
||||
(segments[1] >> 8) as u8,
|
||||
segments[1] as u8,
|
||||
(segments[2] >> 8) as u8,
|
||||
segments[2] as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_teredo_client_ipv4(ip: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
|
||||
let segments = ip.segments();
|
||||
if segments[0] != 0x2001 || segments[1] != 0 {
|
||||
return None;
|
||||
}
|
||||
Some(std::net::Ipv4Addr::new(
|
||||
(!(segments[6] >> 8)) as u8,
|
||||
(!segments[6]) as u8,
|
||||
(!(segments[7] >> 8)) as u8,
|
||||
(!segments[7]) as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn inspect_image_for_proxy_inner(input: &[u8], options: ImageInspectionOptions) -> AnyResult<ImageInspection> {
|
||||
let inspection = parse_image_header(input).context("failed to decode image")?;
|
||||
validate_image_dimensions(&inspection, options)?;
|
||||
Ok(inspection)
|
||||
}
|
||||
|
||||
fn validate_image_dimensions(image: &ImageInspection, options: ImageInspectionOptions) -> AnyResult<()> {
|
||||
let max_width = options.max_width.unwrap_or(MAX_IMAGE_DIMENSION);
|
||||
let max_height = options.max_height.unwrap_or(MAX_IMAGE_DIMENSION);
|
||||
let max_pixels = u64::from(options.max_pixels.unwrap_or(MAX_IMAGE_PIXELS as u32));
|
||||
if image.width == 0 || image.height == 0 {
|
||||
bail!("failed to decode image");
|
||||
}
|
||||
if image.width > max_width || image.height > max_height {
|
||||
bail!("image dimensions exceed limit");
|
||||
}
|
||||
if u64::from(image.width) * u64::from(image.height) > max_pixels {
|
||||
bail!("image pixel count exceeds limit");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_image_header(input: &[u8]) -> AnyResult<ImageInspection> {
|
||||
let format = ::image::guess_format(input).context("unsupported image format")?;
|
||||
let mime_type = image_mime_type(format).context("unsupported image format")?;
|
||||
let (width, height) = ImageReader::with_format(Cursor::new(input), format)
|
||||
.into_dimensions()
|
||||
.context("failed to decode image")?;
|
||||
Ok(ImageInspection {
|
||||
mime_type: mime_type.to_string(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn image_mime_type(format: ImageFormat) -> Option<&'static str> {
|
||||
match format {
|
||||
ImageFormat::Png => Some("image/png"),
|
||||
ImageFormat::Jpeg => Some("image/jpeg"),
|
||||
ImageFormat::Gif => Some("image/gif"),
|
||||
ImageFormat::WebP => Some("image/webp"),
|
||||
ImageFormat::Bmp => Some("image/bmp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blocks_private_ips() {
|
||||
assert!(is_blocked_ip("127.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("10.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("169.254.169.254".parse().unwrap()));
|
||||
assert!(is_blocked_ip("::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("::ffff:127.0.0.1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("2002:7f00:0001::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip("2002:c0a8:0001::1".parse().unwrap()));
|
||||
assert!(is_blocked_ip(
|
||||
"2001:0000:4136:e378:8000:63bf:807f:fffe".parse().unwrap()
|
||||
));
|
||||
assert!(!is_blocked_ip("8.8.8.8".parse().unwrap()));
|
||||
assert!(!is_blocked_ip("2002:0808:0808::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_https_client_with_embedded_roots() {
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
let addrs = ["93.184.216.34:443".parse().unwrap()];
|
||||
build_pinned_client(&url, &addrs, Duration::from_secs(1)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_png_dimensions_without_decode() {
|
||||
let png = base64_simd::STANDARD
|
||||
.decode_to_vec(b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfJ8AAAAASUVORK5CYII=")
|
||||
.unwrap();
|
||||
let inspected = inspect_image_for_proxy_inner(
|
||||
&png,
|
||||
ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(inspected.mime_type, "image/png");
|
||||
assert_eq!(inspected.width, 1);
|
||||
assert_eq!(inspected.height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_dimensions() {
|
||||
let png = [
|
||||
b"\x89PNG\r\n\x1a\n".as_slice(),
|
||||
&[0, 0, 0, 13],
|
||||
b"IHDR".as_slice(),
|
||||
&100_000u32.to_be_bytes(),
|
||||
&100_000u32.to_be_bytes(),
|
||||
&[8, 6, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert!(
|
||||
inspect_image_for_proxy_inner(
|
||||
&png,
|
||||
ImageInspectionOptions {
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
max_pixels: None,
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
fn invalid_arg(error: impl ToString) -> Error {
|
||||
Error::new(Status::InvalidArg, error.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user