938 lines
30 KiB
Rust
938 lines
30 KiB
Rust
#![doc = include_str!("../README.md")]
|
|
#![warn(clippy::all, clippy::pedantic)]
|
|
#![warn(unsafe_code)]
|
|
#![warn(missing_docs)]
|
|
#![allow(clippy::missing_errors_doc, clippy::module_name_repetitions)]
|
|
|
|
#[cfg(feature = "governor")]
|
|
use std::net::SocketAddr;
|
|
use std::{
|
|
borrow::Cow,
|
|
fmt::Display,
|
|
marker::PhantomData,
|
|
sync::{atomic::AtomicU64, Arc, RwLock},
|
|
};
|
|
|
|
#[cfg(feature = "governor")]
|
|
use axum::extract::ConnectInfo;
|
|
use axum::{
|
|
body::Body,
|
|
extract::{Path, Query, State},
|
|
http::{self, HeaderMap, StatusCode},
|
|
response::{IntoResponse, Redirect, Response},
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use fetch::{HTTPResponse, IncomingInfo, UpstreamClient, DEFAULT_MAX_REDIRECTS};
|
|
#[cfg(feature = "governor")]
|
|
use governor::{
|
|
clock::SystemClock, middleware::StateInformationMiddleware, state::keyed::DashMapStateStore,
|
|
RateLimiter,
|
|
};
|
|
use lru::LruCache;
|
|
use post_process::{CompressionLevel, MediaResponse};
|
|
use sandbox::Sandboxing;
|
|
|
|
use serde::Deserialize;
|
|
#[cfg(feature = "cf-worker")]
|
|
use worker::{event, Context, Env, HttpRequest, Result as WorkerResult};
|
|
|
|
use config::{
|
|
Config, FetchConfig, IndexConfig, NormalizationPolicy, PostProcessConfig, SandboxConfig,
|
|
};
|
|
|
|
/// Module for fetching media from upstream
|
|
pub mod fetch;
|
|
/// Module for post-processing media
|
|
pub mod post_process;
|
|
/// Sandbox utilities for processing media
|
|
pub mod sandbox;
|
|
/// Stream utilities
|
|
pub mod stream;
|
|
|
|
/// Configuration utilities
|
|
pub mod config;
|
|
|
|
/// Cross platform timing utilities
|
|
pub mod timing;
|
|
|
|
/// Utilities for Cloudflare Workers
|
|
#[cfg(feature = "cf-worker")]
|
|
mod cf_utils;
|
|
|
|
const MAX_SIZE: usize = 128 << 20;
|
|
|
|
#[cfg(all(not(feature = "cf-worker"), not(feature = "reqwest")))]
|
|
compile_error!("At least one of the `cf-worker` or `reqwest` features must be enabled. hint: '--features env-local' enables everything related to local runtime");
|
|
|
|
#[cfg(feature = "cf-worker")]
|
|
/// The upstream client chosen by the build configuration
|
|
pub type Upstream = crate::fetch::cf_worker::CfWorkerClient;
|
|
|
|
#[cfg(all(not(feature = "cf-worker"), feature = "reqwest"))]
|
|
/// The upstream client chosen by the build configuration
|
|
pub type Upstream = crate::fetch::reqwest::ReqwestClient;
|
|
|
|
#[cfg(feature = "cf-worker")]
|
|
#[event(fetch)]
|
|
async fn fetch(
|
|
req: HttpRequest,
|
|
env: Env,
|
|
_ctx: Context,
|
|
) -> WorkerResult<axum::http::Response<axum::body::Body>> {
|
|
use fetch::cf_worker::CfWorkerClient;
|
|
use sandbox::NoSandbox;
|
|
use tower_service::Service;
|
|
|
|
log::set_logger(&cf_utils::CfLogger).ok();
|
|
|
|
let config = match Config::load_from_cf_env(env) {
|
|
Ok(config) => config,
|
|
Err(e) => {
|
|
log::error!("Failed to load configuration: {}", e);
|
|
return Ok(ErrorResponse::worker_config_error(e).into_response());
|
|
}
|
|
};
|
|
|
|
#[cfg(feature = "panic-console-error")]
|
|
console_error_panic_hook::set_once();
|
|
Ok(router::<CfWorkerClient, NoSandbox>(config)
|
|
.call(req)
|
|
.await?)
|
|
}
|
|
|
|
#[cfg(any(feature = "cf-worker", feature = "reqwest"))]
|
|
/// Application Router
|
|
pub fn router<C: UpstreamClient + 'static, S: Sandboxing + Send + Sync + 'static>(
|
|
config: Config,
|
|
) -> Router
|
|
where
|
|
<<C as UpstreamClient>::Response as HTTPResponse>::BodyStream: Unpin,
|
|
{
|
|
use axum::middleware;
|
|
#[cfg(feature = "governor")]
|
|
use governor::{
|
|
clock::SystemClock, middleware::StateInformationMiddleware, Quota, RateLimiter,
|
|
};
|
|
#[cfg(feature = "governor")]
|
|
use std::time::Duration;
|
|
use std::{num::NonZero, sync::RwLock};
|
|
#[cfg(not(feature = "cf-worker"))]
|
|
use tower_http::{catch_panic::CatchPanicLayer, timeout::TimeoutLayer};
|
|
|
|
let state = AppState {
|
|
#[cfg(feature = "governor")]
|
|
limiters: config
|
|
.rate_limit
|
|
.iter()
|
|
.map(|x| {
|
|
(
|
|
RateLimiter::dashmap_with_clock(
|
|
Quota::with_period(Duration::from_millis(x.replenish_every))
|
|
.unwrap()
|
|
.allow_burst(x.burst),
|
|
SystemClock,
|
|
)
|
|
.with_middleware::<StateInformationMiddleware>(),
|
|
RwLock::new(LruCache::new(NonZero::new(1024).unwrap())),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.into_boxed_slice(),
|
|
client: Upstream::new(&config.fetch),
|
|
sandbox: S::new(&config.sandbox),
|
|
config,
|
|
};
|
|
|
|
let state = Arc::new(state);
|
|
|
|
#[allow(unused_mut)]
|
|
let mut router = Router::new()
|
|
.route("/", get(App::<C, S>::index))
|
|
.route(
|
|
"/proxy",
|
|
get(App::<C, S>::proxy_without_filename)
|
|
.head(App::<C, S>::proxy_without_filename)
|
|
.options(App::<C, S>::proxy_options)
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
set_cache_control,
|
|
))
|
|
.fallback(|| async { ErrorResponse::method_not_allowed() }),
|
|
)
|
|
.route(
|
|
"/proxy/",
|
|
get(App::<C, S>::proxy_without_filename)
|
|
.head(App::<C, S>::proxy_without_filename)
|
|
.options(App::<C, S>::proxy_options)
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
set_cache_control,
|
|
))
|
|
.fallback(|| async { ErrorResponse::method_not_allowed() }),
|
|
)
|
|
.route(
|
|
"/proxy/:filename",
|
|
get(App::<C, S>::proxy_with_filename)
|
|
.head(App::<C, S>::proxy_with_filename)
|
|
.options(App::<C, S>::proxy_options)
|
|
.route_layer(middleware::from_fn_with_state(
|
|
state.clone(),
|
|
set_cache_control,
|
|
))
|
|
.fallback(|| async { ErrorResponse::method_not_allowed() }),
|
|
)
|
|
.layer(middleware::from_fn(common_security_headers))
|
|
.with_state(Arc::clone(&state));
|
|
|
|
#[cfg(not(feature = "cf-worker"))]
|
|
{
|
|
router = router
|
|
.layer(CatchPanicLayer::custom(|err| {
|
|
log::error!("Panic in request: {:?}", err);
|
|
ErrorResponse::postprocess_failed("Internal server error".into()).into_response()
|
|
}))
|
|
.layer(TimeoutLayer::new(Duration::from_secs(10)));
|
|
}
|
|
|
|
#[cfg(feature = "governor")]
|
|
{
|
|
let state_gc = Arc::clone(&state);
|
|
std::thread::spawn(move || loop {
|
|
std::thread::sleep(Duration::from_secs(300));
|
|
for (limiter, _) in state_gc.limiters.iter() {
|
|
limiter.retain_recent();
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "governor")]
|
|
return router.route_layer(middleware::from_fn_with_state(state, rate_limit_middleware));
|
|
#[cfg(not(feature = "governor"))]
|
|
router
|
|
}
|
|
|
|
/// Set the Cache-Control header
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn set_cache_control<S: Sandboxing + Send + Sync + 'static>(
|
|
State(state): State<Arc<AppState<Upstream, S>>>,
|
|
request: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
let mut resp = next.run(request).await;
|
|
if state.config.enable_cache {
|
|
if resp.status() == StatusCode::OK {
|
|
let headers = resp.headers_mut();
|
|
headers.insert(
|
|
"Cache-Control",
|
|
"public, max-age=31536000, immutable".parse().unwrap(),
|
|
);
|
|
} else {
|
|
let headers = resp.headers_mut();
|
|
headers.insert("Cache-Control", "max-age=300".parse().unwrap());
|
|
}
|
|
} else {
|
|
let headers = resp.headers_mut();
|
|
headers.insert("Cache-Control", "no-store".parse().unwrap());
|
|
}
|
|
resp
|
|
}
|
|
|
|
/// Middleware for common security headers
|
|
pub async fn common_security_headers(
|
|
request: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
let mut resp = next.run(request).await;
|
|
let hdr = resp.headers_mut();
|
|
|
|
if !hdr.contains_key("Content-Security-Policy") {
|
|
hdr.insert(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'".parse().unwrap(),
|
|
);
|
|
}
|
|
hdr.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
|
|
hdr.insert("X-Frame-Options", "DENY".parse().unwrap());
|
|
hdr.insert("X-XSS-Protection", "1; mode=block".parse().unwrap());
|
|
hdr.insert("Permissions-Policy", "interest-cohort=()".parse().unwrap());
|
|
|
|
resp
|
|
}
|
|
|
|
fn atomic_u64_saturating_dec(credits: &AtomicU64) -> bool {
|
|
loop {
|
|
let current = credits.load(std::sync::atomic::Ordering::Relaxed);
|
|
if current == 0 {
|
|
return false;
|
|
}
|
|
if credits.compare_exchange_weak(
|
|
current,
|
|
current - 1,
|
|
std::sync::atomic::Ordering::Relaxed,
|
|
std::sync::atomic::Ordering::Relaxed,
|
|
) == Ok(current)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Middleware for rate limiting
|
|
#[cfg(feature = "governor")]
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn rate_limit_middleware<S: Sandboxing + Send + Sync + 'static>(
|
|
State(state): State<Arc<AppState<Upstream, S>>>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
request: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
use std::{
|
|
net::{IpAddr, Ipv6Addr},
|
|
time::SystemTime,
|
|
};
|
|
|
|
use axum::http::HeaderName;
|
|
|
|
let forwarded_ip = if state.config.max_x_forwarded_for > 0 {
|
|
std::iter::repeat(addr.ip())
|
|
.chain(
|
|
request
|
|
.headers()
|
|
.get("x-forwarded-for")
|
|
.and_then(|x| x.to_str().ok())
|
|
.unwrap_or("")
|
|
.split(',')
|
|
.filter_map(|x| x.trim().parse().ok()),
|
|
)
|
|
.nth_back(state.config.max_x_forwarded_for as usize - 1)
|
|
.map(|addr| match addr {
|
|
IpAddr::V6(addr) => IpAddr::V6(addr & Ipv6Addr::from_bits(!0u128 >> 64)),
|
|
addr => addr,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let real_ip = forwarded_ip.unwrap_or_else(|| match addr.ip() {
|
|
IpAddr::V6(addr) => IpAddr::V6(addr & Ipv6Addr::from_bits(!0u128 >> 64)),
|
|
addr => addr,
|
|
});
|
|
|
|
let mut res = Vec::with_capacity(state.limiters.len());
|
|
match state.limiters.iter().fold(Ok(()), |acc, limiter| {
|
|
acc.and_then(|_| {
|
|
// if we have credits, we don't need to check the key
|
|
let credits = limiter.1.read().unwrap();
|
|
if let Some(credits_val) = credits.peek(&forwarded_ip.unwrap_or(real_ip)) {
|
|
if atomic_u64_saturating_dec(credits_val) {
|
|
res.push(None);
|
|
return Ok(());
|
|
}
|
|
|
|
drop(credits);
|
|
let mut credits = limiter.1.write().unwrap();
|
|
credits.pop(&forwarded_ip.unwrap_or(real_ip));
|
|
}
|
|
|
|
limiter
|
|
.0
|
|
.check_key(&forwarded_ip.unwrap_or(real_ip))
|
|
.map(|x| res.push(Some(x)))
|
|
})
|
|
}) {
|
|
Ok(_) => {
|
|
let begin = SystemTime::now();
|
|
let mut resp = next.run(request).await;
|
|
let elapsed = begin.elapsed().unwrap().as_millis() as u64;
|
|
|
|
// credit back limits that didn't get used
|
|
for (config, (_, credits)) in state.config.rate_limit.iter().zip(state.limiters.iter())
|
|
{
|
|
if elapsed < config.min_request_duration.unwrap_or(0) {
|
|
let mut credits = credits.write().unwrap();
|
|
credits
|
|
.get_or_insert(forwarded_ip.unwrap_or(real_ip), || 0.into())
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
let headers = resp.headers_mut();
|
|
|
|
for (config, snapshot) in
|
|
state
|
|
.config
|
|
.rate_limit
|
|
.iter()
|
|
.zip(res.iter())
|
|
.filter_map(|(config, snapshot)| match snapshot {
|
|
Some(snapshot) => Some((config, snapshot)),
|
|
None => None,
|
|
})
|
|
{
|
|
let header_prefix = match config.key {
|
|
Some(ref key) => format!("X-{}-RateLimit-", key),
|
|
None => "X-RateLimit-".to_string(),
|
|
};
|
|
let (header_limit, header_interval, header_remaining) = (
|
|
format!("{}Limit", header_prefix),
|
|
format!("{}Replenish-Interval", header_prefix),
|
|
format!("{}Remaining", header_prefix),
|
|
);
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_limit.as_bytes()).unwrap(),
|
|
#[allow(clippy::unwrap_used)]
|
|
snapshot.quota().burst_size().to_string().parse().unwrap(),
|
|
);
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_interval.as_bytes()).unwrap(),
|
|
config.replenish_every.to_string().parse().unwrap(),
|
|
);
|
|
headers.insert("X-RateLimit-Remaining", "0".parse().unwrap());
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_remaining.as_bytes()).unwrap(),
|
|
snapshot
|
|
.remaining_burst_capacity()
|
|
.to_string()
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
}
|
|
|
|
resp
|
|
}
|
|
Err(err) => {
|
|
log::warn!("Rate limit exceeded for {}: {}", addr, err);
|
|
let mut resp = ErrorResponse::rate_limit_exceeded().into_response();
|
|
|
|
let headers = resp.headers_mut();
|
|
|
|
for (config, snapshot) in
|
|
state
|
|
.config
|
|
.rate_limit
|
|
.iter()
|
|
.zip(res.iter())
|
|
.filter_map(|(config, snapshot)| match snapshot {
|
|
Some(snapshot) => Some((config, snapshot)),
|
|
None => None,
|
|
})
|
|
{
|
|
let header_prefix = match config.key {
|
|
Some(ref key) => format!("X-{}-RateLimit-", key),
|
|
None => "X-RateLimit-".to_string(),
|
|
};
|
|
let (header_limit, header_interval, header_remaining) = (
|
|
format!("{}Limit", header_prefix),
|
|
format!("{}Replenish-Interval", header_prefix),
|
|
format!("{}Remaining", header_prefix),
|
|
);
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_limit.as_bytes()).unwrap(),
|
|
#[allow(clippy::unwrap_used)]
|
|
snapshot.quota().burst_size().to_string().parse().unwrap(),
|
|
);
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_interval.as_bytes()).unwrap(),
|
|
config.replenish_every.to_string().parse().unwrap(),
|
|
);
|
|
|
|
headers.insert("X-RateLimit-Remaining", "0".parse().unwrap());
|
|
headers.insert(
|
|
HeaderName::from_bytes(header_remaining.as_bytes()).unwrap(),
|
|
snapshot
|
|
.remaining_burst_capacity()
|
|
.to_string()
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
}
|
|
|
|
headers.insert(
|
|
"Retry-After",
|
|
err.earliest_possible()
|
|
.duration_since(SystemTime::now())
|
|
.unwrap()
|
|
.as_secs()
|
|
.into(),
|
|
);
|
|
|
|
resp
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Query parameters for the proxy endpoint
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
#[allow(missing_docs)]
|
|
pub struct ProxyQuery {
|
|
pub url: String,
|
|
#[serde(flatten)]
|
|
pub image_options: ImageOptions,
|
|
}
|
|
|
|
fn deserialize_query_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
Option::<String>::deserialize(deserializer)?.map_or(Ok(None), |s| match s.as_str() {
|
|
"true" | "True" | "TRUE" | "1" | "Y" | "yes" | "Yes" | "YES" => Ok(Some(true)),
|
|
"false" | "False" | "FALSE" | "0" | "N" | "no" | "No" | "NO" => Ok(Some(false)),
|
|
_ => Err(serde::de::Error::custom("expected 'true' or 'false'")),
|
|
})
|
|
}
|
|
|
|
/// Query options for the proxy endpoint
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
pub struct ImageOptions {
|
|
/// If set to true, always proxy the image instead of redirecting
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
pub origin: Option<bool>,
|
|
/// See upstream specification
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
pub avatar: Option<bool>,
|
|
/// See upstream specification
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
#[serde(rename = "static")]
|
|
pub static_: Option<bool>,
|
|
/// See upstream specification
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
pub preview: Option<bool>,
|
|
/// See upstream specification
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
pub badge: Option<bool>,
|
|
/// See upstream specification
|
|
#[serde(default, deserialize_with = "deserialize_query_bool")]
|
|
pub emoji: Option<bool>,
|
|
/// Set the preferred format, see [`NormalizationPolicy`] for more information
|
|
pub format: Option<String>,
|
|
}
|
|
|
|
impl ImageOptions {
|
|
/// Convert options to compression level
|
|
#[must_use]
|
|
pub fn compression_level(&self) -> CompressionLevel {
|
|
if self.badge == Some(true) {
|
|
return CompressionLevel::Med;
|
|
}
|
|
|
|
if self.avatar == Some(true) {
|
|
return CompressionLevel::High;
|
|
}
|
|
|
|
if self.emoji == Some(true) {
|
|
return CompressionLevel::Max;
|
|
}
|
|
|
|
if self.preview == Some(true) {
|
|
return CompressionLevel::Med;
|
|
}
|
|
|
|
if self.static_ == Some(true) {
|
|
return CompressionLevel::Low;
|
|
}
|
|
|
|
CompressionLevel::None
|
|
}
|
|
/// Whether resizing is requested
|
|
#[must_use]
|
|
pub fn requested_resize(&self) -> bool {
|
|
self.avatar.is_some()
|
|
|| self.preview.is_some()
|
|
|| self.badge.is_some()
|
|
|| self.emoji.is_some()
|
|
}
|
|
/// Apply preferred format and image type from filename
|
|
pub fn apply_filename(&mut self, filename: &str) {
|
|
let mut split = filename.split('.');
|
|
let stem = split.next().unwrap_or_default();
|
|
let ext = split.last().unwrap_or_default();
|
|
match ext {
|
|
"png" => self.format = Some("png".to_string()),
|
|
"jpg" | "jpeg" => self.format = Some("jpeg".to_string()),
|
|
"webp" => self.format = Some("webp".to_string()),
|
|
"tiff" => self.format = Some("tiff".to_string()),
|
|
"gif" => self.format = Some("gif".to_string()),
|
|
"bmp" => self.format = Some("bmp".to_string()),
|
|
_ => {}
|
|
}
|
|
match stem {
|
|
"avatar" => self.avatar = Some(true),
|
|
"static" => self.static_ = Some(true),
|
|
"preview" => self.preview = Some(true),
|
|
"badge" => self.badge = Some(true),
|
|
"emoji" => self.emoji = Some(true),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[allow(
|
|
clippy::trivially_copy_pass_by_ref,
|
|
reason = "Serde requires references"
|
|
)]
|
|
fn serialize_status<S>(status: &StatusCode, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
serializer.serialize_u16(status.as_u16())
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
/// Error response
|
|
#[allow(missing_docs)]
|
|
pub struct ErrorResponse {
|
|
#[serde(serialize_with = "serialize_status")]
|
|
pub status: StatusCode,
|
|
pub message: Cow<'static, str>,
|
|
}
|
|
|
|
impl Display for ErrorResponse {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}: {}", self.status, self.message)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ErrorResponse {}
|
|
|
|
impl ErrorResponse {
|
|
#[cfg(not(feature = "cf-worker"))]
|
|
/// URL must be a DNS name
|
|
#[must_use]
|
|
pub const fn non_dns_name() -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
message: Cow::Borrowed("URL must be a DNS name"),
|
|
}
|
|
}
|
|
/// Entropy source exhausted
|
|
#[must_use]
|
|
pub const fn entropy_exhausted() -> Self {
|
|
Self {
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
message: Cow::Borrowed("Entropy source exhausted"),
|
|
}
|
|
}
|
|
/// Worker is configured improperly
|
|
#[cfg(feature = "cf-worker")]
|
|
#[must_use]
|
|
pub fn worker_config_error(err: CfConfigError) -> Self {
|
|
match err {
|
|
CfConfigError::EnvConversion => Self {
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
message: Cow::Borrowed("Failed to convert config to JS object"),
|
|
},
|
|
CfConfigError::JsonParse(e) => Self {
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
message: format!("Failed to parse config object: {e}").into(),
|
|
},
|
|
}
|
|
}
|
|
/// Method not allowed
|
|
#[must_use]
|
|
pub const fn method_not_allowed() -> Self {
|
|
Self {
|
|
status: StatusCode::METHOD_NOT_ALLOWED,
|
|
message: Cow::Borrowed("Method not allowed"),
|
|
}
|
|
}
|
|
/// Upstream request timed out
|
|
#[must_use]
|
|
pub const fn upstream_timeout() -> Self {
|
|
Self {
|
|
status: StatusCode::GATEWAY_TIMEOUT,
|
|
message: Cow::Borrowed("Upstream request timed out"),
|
|
}
|
|
}
|
|
/// Unexpected status code
|
|
#[must_use]
|
|
pub fn unexpected_status(url: &str, status: u16) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
message: format!("Unexpected status code when accessing {url}: {status}").into(),
|
|
}
|
|
}
|
|
/// Insecure request
|
|
#[must_use]
|
|
pub const fn insecure_request() -> Self {
|
|
Self {
|
|
status: StatusCode::FORBIDDEN,
|
|
message: Cow::Borrowed("HTTP requests are disabled"),
|
|
}
|
|
}
|
|
/// Rate limit exceeded
|
|
#[must_use]
|
|
pub const fn rate_limit_exceeded() -> Self {
|
|
Self {
|
|
status: StatusCode::TOO_MANY_REQUESTS,
|
|
message: Cow::Borrowed("Rate limit exceeded"),
|
|
}
|
|
}
|
|
/// Bad URL
|
|
#[must_use]
|
|
pub const fn bad_url() -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
message: Cow::Borrowed("Bad URL"),
|
|
}
|
|
}
|
|
/// Upstream sent invalid HTTP response
|
|
#[must_use]
|
|
pub fn upstream_protocol_error() -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
message: Cow::Borrowed("Upstream protocol error"),
|
|
}
|
|
}
|
|
/// Too many redirects
|
|
#[must_use]
|
|
pub const fn too_many_redirects() -> Self {
|
|
Self {
|
|
status: StatusCode::TOO_MANY_REQUESTS,
|
|
message: Cow::Borrowed("Too many redirects"),
|
|
}
|
|
}
|
|
/// Cloudflare worker reported an error
|
|
#[cfg(feature = "cf-worker")]
|
|
#[must_use]
|
|
#[allow(clippy::needless_pass_by_value)]
|
|
pub fn worker_fetch_error(e: worker::Error) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
message: Cow::Owned(e.to_string()),
|
|
}
|
|
}
|
|
/// Requested media is too large
|
|
#[must_use]
|
|
pub const fn loop_detected() -> Self {
|
|
Self {
|
|
status: StatusCode::LOOP_DETECTED,
|
|
message: Cow::Borrowed(
|
|
"Loop detected, please make sure your User-Agent and Via headers are not being stripped",
|
|
),
|
|
}
|
|
}
|
|
/// Received more data than allowed in one receive
|
|
#[must_use]
|
|
pub const fn mtu_buffer_overflow() -> Self {
|
|
Self {
|
|
status: StatusCode::PAYLOAD_TOO_LARGE,
|
|
message: Cow::Borrowed("MTU buffer overflow"),
|
|
}
|
|
}
|
|
/// Requested media is too large
|
|
#[must_use]
|
|
pub const fn payload_too_large() -> Self {
|
|
Self {
|
|
status: StatusCode::PAYLOAD_TOO_LARGE,
|
|
message: Cow::Borrowed("Payload too large"),
|
|
}
|
|
}
|
|
/// Post-processing failed
|
|
#[must_use]
|
|
pub const fn postprocess_failed(msg: Cow<'static, str>) -> Self {
|
|
Self {
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
message: msg,
|
|
}
|
|
}
|
|
/// Requested media is unsafe
|
|
#[must_use]
|
|
pub const fn unsafe_media() -> Self {
|
|
Self {
|
|
status: StatusCode::FORBIDDEN,
|
|
message: Cow::Borrowed("Unsafe media type"),
|
|
}
|
|
}
|
|
/// Requested media can not be processed
|
|
#[must_use]
|
|
pub const fn unsupported_media() -> Self {
|
|
Self {
|
|
status: StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
message: Cow::Borrowed("Unsupported media type"),
|
|
}
|
|
}
|
|
/// Requested media is not a media file
|
|
#[must_use]
|
|
pub const fn not_media() -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_REQUEST,
|
|
message: Cow::Borrowed("Not a media file"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "cf-worker")]
|
|
impl From<worker::Error> for ErrorResponse {
|
|
fn from(e: worker::Error) -> Self {
|
|
Self {
|
|
status: StatusCode::INTERNAL_SERVER_ERROR,
|
|
message: Cow::Owned(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "reqwest")]
|
|
impl From<reqwest::Error> for ErrorResponse {
|
|
fn from(e: reqwest::Error) -> Self {
|
|
Self {
|
|
status: StatusCode::BAD_GATEWAY,
|
|
message: Cow::Owned(e.to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ErrorResponse {
|
|
fn into_response(self) -> axum::response::Response {
|
|
(self.status, Json(self)).into_response()
|
|
}
|
|
}
|
|
|
|
/// Application state
|
|
#[allow(unused)]
|
|
pub struct AppState<C: UpstreamClient, S: Sandboxing> {
|
|
#[cfg(feature = "governor")]
|
|
limiters: Box<
|
|
[(
|
|
RateLimiter<
|
|
std::net::IpAddr,
|
|
DashMapStateStore<std::net::IpAddr>,
|
|
SystemClock,
|
|
StateInformationMiddleware,
|
|
>,
|
|
RwLock<LruCache<std::net::IpAddr, AtomicU64>>,
|
|
)],
|
|
>,
|
|
config: Config,
|
|
client: C,
|
|
sandbox: S,
|
|
}
|
|
|
|
/// App routes
|
|
pub struct App<C: UpstreamClient, S: Sandboxing> {
|
|
_marker: PhantomData<(C, S)>,
|
|
}
|
|
|
|
#[cfg(any(feature = "cf-worker", feature = "reqwest"))]
|
|
#[allow(clippy::unused_async)]
|
|
impl<C: UpstreamClient + 'static, S: Sandboxing + Send + Sync + 'static> App<C, S> {
|
|
/// Root endpoint
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn index(State(state): State<Arc<AppState<Upstream, S>>>) -> Response {
|
|
match &state.clone().config.index_redirect {
|
|
IndexConfig::Redirect { permanent, ref url } => {
|
|
if *permanent {
|
|
Redirect::permanent(url).into_response()
|
|
} else {
|
|
Redirect::temporary(url).into_response()
|
|
}
|
|
}
|
|
IndexConfig::Message(msg) => (StatusCode::OK, msg.to_string()).into_response(),
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
async fn proxy_impl<'a>(
|
|
method: http::Method,
|
|
filename: Option<&str>,
|
|
State(state): State<Arc<AppState<Upstream, S>>>,
|
|
Query(query): Query<ProxyQuery>,
|
|
info: IncomingInfo,
|
|
) -> Result<Response, ErrorResponse>
|
|
where
|
|
<<C as UpstreamClient>::Response as HTTPResponse>::BodyStream: Unpin,
|
|
{
|
|
let mut options = query.image_options;
|
|
if let Some(filename) = filename {
|
|
options.apply_filename(filename);
|
|
}
|
|
match method {
|
|
http::Method::GET => {}
|
|
http::Method::HEAD => {
|
|
let mut resp = Response::new(Body::empty());
|
|
resp.headers_mut().insert(
|
|
"Content-Type",
|
|
#[allow(clippy::match_same_arms)]
|
|
match options.format.as_deref() {
|
|
Some("png") => "image/png",
|
|
Some("jpeg" | "jpg") => "image/jpeg",
|
|
Some("webp") => "image/webp",
|
|
_ => "image/webp",
|
|
}
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
return Ok(resp);
|
|
}
|
|
_ => {
|
|
return Err(ErrorResponse::method_not_allowed());
|
|
}
|
|
}
|
|
|
|
log::info!("Proxying {}, options: {:?}", query.url, options);
|
|
|
|
let resp = state
|
|
.client
|
|
.request_upstream(&info, &query.url, false, true, DEFAULT_MAX_REDIRECTS)
|
|
.await?;
|
|
|
|
let media = Box::pin(MediaResponse::from_upstream_response::<S>(
|
|
resp,
|
|
state.config.allow_unknown,
|
|
&state.config.post_process,
|
|
options,
|
|
&state.sandbox,
|
|
))
|
|
.await?;
|
|
|
|
// Just in case we proxied something we shouldn't
|
|
let mut resp = media.into_response();
|
|
resp.headers_mut().insert(
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; img-src 'self'".parse().unwrap(),
|
|
);
|
|
|
|
Ok(resp)
|
|
}
|
|
|
|
/// Proxy endpoint without filename
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn proxy_without_filename(
|
|
method: http::Method,
|
|
Query(query): Query<ProxyQuery>,
|
|
State(state): State<Arc<AppState<Upstream, S>>>,
|
|
info: IncomingInfo,
|
|
) -> Result<Response, ErrorResponse>
|
|
where
|
|
<<C as UpstreamClient>::Response as HTTPResponse>::BodyStream: Unpin,
|
|
{
|
|
Self::proxy_impl(method, None, State(state), Query(query), info).await
|
|
}
|
|
|
|
/// Proxy OPTIONS endpoint
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn proxy_options() -> HeaderMap {
|
|
let mut hm = HeaderMap::new();
|
|
hm.insert(
|
|
"Access-Control-Allow-Methods",
|
|
"GET, OPTIONS".parse().unwrap(),
|
|
);
|
|
|
|
hm
|
|
}
|
|
|
|
/// Proxy endpoint with filename
|
|
#[cfg_attr(feature = "cf-worker", worker::send)]
|
|
pub async fn proxy_with_filename(
|
|
method: http::Method,
|
|
Path(filename): Path<String>,
|
|
State(state): State<Arc<AppState<Upstream, S>>>,
|
|
Query(query): Query<ProxyQuery>,
|
|
info: IncomingInfo,
|
|
) -> Result<Response, ErrorResponse>
|
|
where
|
|
<<C as UpstreamClient>::Response as HTTPResponse>::BodyStream: Unpin,
|
|
{
|
|
Self::proxy_impl(method, Some(&filename), State(state), Query(query), info).await
|
|
}
|
|
}
|