mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
5542 lines
188 KiB
Rust
5542 lines
188 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::{
|
|
asset_hash,
|
|
bunny_ip_gate::{self, BunnyIpGate},
|
|
byte_cache::Cache,
|
|
coalescer::{ByteCoalescer, CoalescerError},
|
|
config::{Config, DeploymentMode},
|
|
constants::{self, AssetExtension, AssetKind},
|
|
disposition, external_path, http_client, http_headers, media_process, metrics, mime,
|
|
output_format, public_net_policy, range,
|
|
request_log::{self, ErrorReason, Stage},
|
|
signing,
|
|
spool::{SpoolError, spool_to_temp},
|
|
storage::{RelayBody, RelayPutOptions, StorageError, Store, StreamObject},
|
|
timed_semaphore::TimedSemaphore,
|
|
upload_relay,
|
|
};
|
|
use anyhow::Context as _;
|
|
use axum::{
|
|
Router,
|
|
body::{Body, to_bytes},
|
|
extract::{ConnectInfo, Path, Query, State},
|
|
http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header},
|
|
middleware,
|
|
response::Response,
|
|
routing::{any, get, post, put},
|
|
};
|
|
use base64::{Engine as _, engine::general_purpose};
|
|
use bytes::Bytes;
|
|
use http_body::Frame;
|
|
use http_body_util::BodyExt;
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::{
|
|
borrow::Cow,
|
|
collections::HashMap,
|
|
net::SocketAddr,
|
|
pin::Pin,
|
|
sync::{Arc, OnceLock},
|
|
task::{Context, Poll},
|
|
time::{Duration, Instant},
|
|
};
|
|
use tokio::net::TcpListener;
|
|
use tracing::{info, warn};
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
cfg: Config,
|
|
store: Store,
|
|
client: http_client::HttpClient,
|
|
nsfw_client: reqwest::Client,
|
|
transform_cache: Arc<Cache>,
|
|
external_hints: ExternalHintCache,
|
|
coalescer: Arc<ByteCoalescer>,
|
|
native_transform_admissions: TimedSemaphore,
|
|
native_transforms: TimedSemaphore,
|
|
}
|
|
|
|
pub async fn run(cfg: Config) -> anyhow::Result<()> {
|
|
metrics::init_global();
|
|
media_process::warmup_vips()?;
|
|
let addr: SocketAddr = format!("{}:{}", cfg.bind_host, cfg.port).parse()?;
|
|
let state = Arc::new(AppState {
|
|
store: Store::try_new(cfg.clone())?,
|
|
client: http_client::build(http_client::Options {
|
|
connect_timeout_ms: cfg.socket_io_timeout_ms.max(1),
|
|
timeout_ms: cfg.socket_io_timeout_ms.max(1),
|
|
restrict_to_public: true,
|
|
..http_client::Options::default()
|
|
})?,
|
|
nsfw_client: reqwest::Client::builder()
|
|
.connect_timeout(std::time::Duration::from_millis(1_500))
|
|
.pool_idle_timeout(std::time::Duration::from_secs(30))
|
|
.user_agent(constants::OUTBOUND_USER_AGENT)
|
|
.build()?,
|
|
transform_cache: Arc::new(Cache::new(
|
|
cfg.transform_cache_capacity_bytes,
|
|
cfg.transform_cache_max_entry_bytes,
|
|
cfg.transform_cache_ttl_ms,
|
|
)),
|
|
external_hints: new_external_hint_cache(&cfg),
|
|
coalescer: Arc::new(ByteCoalescer::new()),
|
|
native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)),
|
|
native_transforms: TimedSemaphore::new(cfg.max_native_transforms),
|
|
cfg,
|
|
});
|
|
let bunny_gate = if state.cfg.bunny_ip_gate_enabled {
|
|
let gate = Arc::new(BunnyIpGate::new(
|
|
bunny_ip_gate::build_refresh_client()?,
|
|
state.cfg.bunny_ip_gate_trusted_proxies.clone(),
|
|
));
|
|
let count = gate
|
|
.refresh_once()
|
|
.await
|
|
.context("initial bunny ip allowlist fetch failed")?;
|
|
info!(
|
|
count,
|
|
trusted_proxies = state.cfg.bunny_ip_gate_trusted_proxies.len(),
|
|
refresh_secs = state.cfg.bunny_ip_gate_refresh_secs,
|
|
"bunny ip gate enabled"
|
|
);
|
|
Arc::clone(&gate)
|
|
.spawn_background_refresher(Duration::from_secs(state.cfg.bunny_ip_gate_refresh_secs));
|
|
Some(gate)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(read_endpoint) = state.cfg.s3_read_endpoint.as_deref() {
|
|
info!(
|
|
endpoint = read_endpoint,
|
|
bucket = state.cfg.s3_read_bucket,
|
|
style = ?state.cfg.s3_read_bucket_style,
|
|
signed = state.cfg.s3_read_signed,
|
|
"object body reads served from the S3 read endpoint"
|
|
);
|
|
}
|
|
let mut router = Router::new()
|
|
.route("/_health", get(health))
|
|
.route("/_metrics", get(metrics_handler))
|
|
.route("/_metadata", post(metadata_handler))
|
|
.route("/_thumbnail", post(thumbnail_handler))
|
|
.route("/_frames", post(frames_handler))
|
|
.route("/v1/relay/{*key}", put(relay_put).options(relay_options))
|
|
.fallback(any(catch_all))
|
|
.layer(middleware::from_fn(add_version_header))
|
|
.layer(middleware::from_fn(request_log::trace));
|
|
if let Some(gate) = bunny_gate {
|
|
router = router.layer(middleware::from_fn_with_state(
|
|
gate,
|
|
bunny_ip_gate::gate_middleware,
|
|
));
|
|
}
|
|
router = router.layer(middleware::from_fn_with_state(
|
|
state.cfg.mode,
|
|
add_security_header_middleware,
|
|
));
|
|
let app = router.with_state(state);
|
|
let listener = TcpListener::bind(addr).await?;
|
|
info!(%addr, "media proxy listening");
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(shutdown_signal())
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn shutdown_signal() {
|
|
let ctrl_c = async {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
};
|
|
#[cfg(unix)]
|
|
let terminate = async {
|
|
let Ok(mut sigterm) =
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
else {
|
|
return;
|
|
};
|
|
sigterm.recv().await;
|
|
};
|
|
#[cfg(not(unix))]
|
|
let terminate = std::future::pending::<()>();
|
|
tokio::select! {
|
|
_ = ctrl_c => {},
|
|
_ = terminate => {},
|
|
}
|
|
}
|
|
|
|
async fn health() -> &'static str {
|
|
"OK"
|
|
}
|
|
|
|
fn build_version() -> &'static str {
|
|
static BUILD_VERSION: OnceLock<String> = OnceLock::new();
|
|
BUILD_VERSION
|
|
.get_or_init(|| {
|
|
std::env::var("BUILD_VERSION")
|
|
.ok()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or_else(|| "dev".to_owned())
|
|
})
|
|
.as_str()
|
|
}
|
|
|
|
async fn add_version_header(request: Request<Body>, next: middleware::Next) -> Response {
|
|
let mut response = next.run(request).await;
|
|
if let Ok(value) = HeaderValue::from_str(build_version()) {
|
|
response.headers_mut().insert("x-fluxer-version", value);
|
|
}
|
|
response
|
|
}
|
|
|
|
async fn add_security_header_middleware(
|
|
State(mode): State<DeploymentMode>,
|
|
request: Request<Body>,
|
|
next: middleware::Next,
|
|
) -> Response {
|
|
let mut response = next.run(request).await;
|
|
let headers = response.headers_mut();
|
|
http_headers::add_security_headers(headers);
|
|
if mode == DeploymentMode::Static {
|
|
headers.remove("X-Robots-Tag");
|
|
}
|
|
response
|
|
}
|
|
|
|
fn is_loopback_peer(peer: &SocketAddr) -> bool {
|
|
peer.ip().to_canonical().is_loopback()
|
|
}
|
|
|
|
async fn metrics_handler(ConnectInfo(peer): ConnectInfo<SocketAddr>) -> Response {
|
|
if !is_loopback_peer(&peer) {
|
|
let mut denied = Response::new(Body::from("FORBIDDEN"));
|
|
*denied.status_mut() = StatusCode::FORBIDDEN;
|
|
http_headers::add_security_headers(denied.headers_mut());
|
|
denied
|
|
.headers_mut()
|
|
.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
|
|
return denied;
|
|
}
|
|
let mut response = Response::new(Body::from(metrics::render()));
|
|
http_headers::add_security_headers(response.headers_mut());
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct MetadataRequest {
|
|
version: Option<i64>,
|
|
#[serde(rename = "type")]
|
|
typ: String,
|
|
nsfw: String,
|
|
base64: Option<String>,
|
|
upload_filename: Option<String>,
|
|
filename: Option<String>,
|
|
bucket: Option<String>,
|
|
key: Option<String>,
|
|
url: Option<String>,
|
|
with_base64: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FramesRequest {
|
|
version: Option<i64>,
|
|
#[serde(rename = "type")]
|
|
typ: String,
|
|
base64: Option<String>,
|
|
upload_filename: Option<String>,
|
|
filename: Option<String>,
|
|
bucket: Option<String>,
|
|
key: Option<String>,
|
|
url: Option<String>,
|
|
}
|
|
|
|
impl FramesRequest {
|
|
fn into_metadata_request(self) -> MetadataRequest {
|
|
MetadataRequest {
|
|
version: self.version,
|
|
typ: self.typ,
|
|
nsfw: "allow".to_owned(),
|
|
base64: self.base64,
|
|
upload_filename: self.upload_filename,
|
|
filename: self.filename,
|
|
bucket: self.bucket,
|
|
key: self.key,
|
|
url: self.url,
|
|
with_base64: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn metadata_handler(
|
|
State(app): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Response {
|
|
if !check_internal_auth(&headers, &app.cfg.secret_key) {
|
|
return text(StatusCode::UNAUTHORIZED, "Unauthorized");
|
|
}
|
|
let body = match read_limited_body(request).await {
|
|
Ok(body) => body,
|
|
Err(status) => return text(status, status.canonical_reason().unwrap_or("Bad Request")),
|
|
};
|
|
let req: MetadataRequest = match serde_json::from_slice::<MetadataRequest>(&body) {
|
|
Ok(req) if req.version == Some(2) => req,
|
|
_ => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let should_scan_nsfw = match req.nsfw.as_str() {
|
|
"block" | "flag" => true,
|
|
"allow" => false,
|
|
_ => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let mut input = match load_metadata_input(&app, &req).await {
|
|
Ok(input) => input,
|
|
Err(status) => return text(status, status.canonical_reason().unwrap_or("Bad Request")),
|
|
};
|
|
if req.with_base64.unwrap_or(false) && metadata_input_is_svg(&input) {
|
|
input = match rasterize_metadata_svg(&app, input).await {
|
|
Ok(input) => input,
|
|
Err(response) => return response,
|
|
};
|
|
}
|
|
let json = match media_process::metadata_json_with_options(
|
|
&input.data,
|
|
&input.filename,
|
|
media_process::MetadataOptions {
|
|
placeholder: true,
|
|
nsfw: if should_scan_nsfw {
|
|
nsfw_config(&app)
|
|
} else {
|
|
crate::nsfw::Config::disabled()
|
|
},
|
|
},
|
|
&app.nsfw_client,
|
|
)
|
|
.await
|
|
{
|
|
Ok(json) => json,
|
|
Err(err) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"metadata_extraction_failed",
|
|
format!("filename={} err={err:?}", input.filename),
|
|
);
|
|
}
|
|
};
|
|
let mut value: serde_json::Value =
|
|
serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({}));
|
|
if req.with_base64.unwrap_or(false) {
|
|
value["base64"] = serde_json::Value::String(general_purpose::STANDARD.encode(&input.data));
|
|
}
|
|
json_response(StatusCode::OK, value.to_string())
|
|
}
|
|
|
|
struct InputData {
|
|
data: Bytes,
|
|
filename: String,
|
|
}
|
|
|
|
fn metadata_input_is_svg(input: &InputData) -> bool {
|
|
mime::sniff(&input.data).mime == "image/svg+xml"
|
|
|| image_extension_from_filename(&input.filename) == Some(AssetExtension::Svg)
|
|
}
|
|
|
|
fn replace_image_extension(filename: &str, ext: AssetExtension) -> String {
|
|
let last_slash = filename.rfind('/').map(|idx| idx + 1).unwrap_or(0);
|
|
let last_dot = filename[last_slash..]
|
|
.rfind('.')
|
|
.map(|idx| last_slash + idx);
|
|
match last_dot {
|
|
Some(idx) => format!("{}.{}", &filename[..idx], ext.name()),
|
|
None => format!("{}.{}", filename, ext.name()),
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::result_large_err)]
|
|
async fn rasterize_metadata_svg(
|
|
app: &Arc<AppState>,
|
|
input: InputData,
|
|
) -> Result<InputData, Response> {
|
|
let options = media_process::ImageOptions {
|
|
format: AssetExtension::Webp,
|
|
quality: "lossless".to_owned(),
|
|
animated: false,
|
|
deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64),
|
|
max_encode_frames: Some(app.cfg.max_encode_frames),
|
|
max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms),
|
|
..Default::default()
|
|
};
|
|
match run_transform(app, input.data, options).await {
|
|
Ok(media) => Ok(InputData {
|
|
data: media.bytes.into(),
|
|
filename: replace_image_extension(&input.filename, AssetExtension::Webp),
|
|
}),
|
|
Err(err) if transform_error_is_timeout(&err) => Err(text_with_source(
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
"Gateway Timeout",
|
|
"metadata_svg_rasterize_timeout",
|
|
input.filename,
|
|
)),
|
|
Err(err) => Err(text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"metadata_svg_rasterize_failed",
|
|
format!("filename={} err={err:?}", input.filename),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn load_metadata_input(
|
|
app: &AppState,
|
|
req: &MetadataRequest,
|
|
) -> Result<InputData, StatusCode> {
|
|
match req.typ.as_str() {
|
|
"base64" => {
|
|
let raw = req.base64.as_deref().ok_or(StatusCode::BAD_REQUEST)?;
|
|
let b64 = raw.rsplit_once(',').map(|(_, v)| v).unwrap_or(raw);
|
|
if b64.len() > constants::MAX_INTERNAL_REQUEST_BODY_BYTES {
|
|
warn!(reason = "metadata_base64_too_large", len = b64.len());
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
let data = general_purpose::STANDARD.decode(b64).map_err(|err| {
|
|
warn!(reason = "metadata_base64_decode", ?err);
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
if data.len() > constants::MAX_MEDIA_PROXY_BYTES {
|
|
warn!(reason = "metadata_decoded_too_large", len = data.len());
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
Ok(InputData {
|
|
data: Bytes::from(data),
|
|
filename: req
|
|
.filename
|
|
.clone()
|
|
.unwrap_or_else(|| "inline.bin".to_owned()),
|
|
})
|
|
}
|
|
"upload" => {
|
|
let key = req
|
|
.upload_filename
|
|
.as_deref()
|
|
.ok_or(StatusCode::BAD_REQUEST)?;
|
|
let object = app
|
|
.store
|
|
.read_object(&app.cfg.bucket_uploads, key)
|
|
.await
|
|
.map_err(|err| {
|
|
warn!(reason = "metadata_upload_read", key, %err);
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
Ok(InputData {
|
|
data: object.data,
|
|
filename: req.filename.clone().unwrap_or_else(|| key.to_owned()),
|
|
})
|
|
}
|
|
"s3" => {
|
|
let bucket = req.bucket.as_deref().ok_or(StatusCode::BAD_REQUEST)?;
|
|
let key = req.key.as_deref().ok_or(StatusCode::BAD_REQUEST)?;
|
|
let object = app.store.read_object(bucket, key).await.map_err(|err| {
|
|
warn!(reason = "metadata_s3_read", bucket, key, %err);
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
Ok(InputData {
|
|
data: object.data,
|
|
filename: req.filename.clone().unwrap_or_else(|| key.to_owned()),
|
|
})
|
|
}
|
|
"external" => {
|
|
let url = req.url.as_deref().ok_or(StatusCode::BAD_REQUEST)?;
|
|
let fetched = fetch_external(app, url).await.map_err(|err| {
|
|
warn!(reason = "metadata_external_fetch", url, ?err);
|
|
match err {
|
|
ExternalFetchError::BlockedUrl => StatusCode::BAD_REQUEST,
|
|
ExternalFetchError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
|
ExternalFetchError::TooManyRedirects | ExternalFetchError::FetchFailed => {
|
|
StatusCode::BAD_GATEWAY
|
|
}
|
|
}
|
|
})?;
|
|
if !fetched.status.is_success() {
|
|
warn!(
|
|
reason = "metadata_external_status",
|
|
url,
|
|
status = fetched.status.as_u16()
|
|
);
|
|
return Err(map_internal_metadata_upstream_status(fetched.status));
|
|
}
|
|
let data = fetched
|
|
.body
|
|
.into_buffered(&fetched.url)
|
|
.await
|
|
.map_err(|err| {
|
|
warn!(reason = "metadata_external_body", url, ?err);
|
|
match err {
|
|
ExternalFetchError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
}
|
|
})?;
|
|
Ok(InputData {
|
|
data,
|
|
filename: req
|
|
.filename
|
|
.clone()
|
|
.unwrap_or_else(|| url_filename(&fetched.url)),
|
|
})
|
|
}
|
|
_ => Err(StatusCode::BAD_REQUEST),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UploadFileRequest {
|
|
upload_filename: String,
|
|
}
|
|
|
|
async fn thumbnail_handler(
|
|
State(app): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Response {
|
|
if !check_internal_auth(&headers, &app.cfg.secret_key) {
|
|
return text(StatusCode::UNAUTHORIZED, "Unauthorized");
|
|
}
|
|
let body = match read_limited_body(request).await {
|
|
Ok(body) => body,
|
|
Err(status) => return text(status, "Bad Request"),
|
|
};
|
|
let req: UploadFileRequest = match serde_json::from_slice(&body) {
|
|
Ok(req) => req,
|
|
Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let object = match app
|
|
.store
|
|
.read_object(&app.cfg.bucket_uploads, &req.upload_filename)
|
|
.await
|
|
{
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(&req.upload_filename, err),
|
|
};
|
|
let media = if mime::category(&object.content_type) == Some(mime::Category::Video) {
|
|
match media_process::extract_video_thumbnail(&object.data, AssetExtension::Webp) {
|
|
Ok(media) => media,
|
|
Err(err) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"video_thumbnail_failed",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
let options = media_process::ImageOptions {
|
|
width: Some(512),
|
|
height: Some(512),
|
|
format: AssetExtension::Webp,
|
|
cover_crop: false,
|
|
..Default::default()
|
|
};
|
|
match run_transform(&app, object.data.clone(), options).await {
|
|
Ok(media) => media,
|
|
Err(err) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"image_thumbnail_failed",
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
media_response(
|
|
Method::GET,
|
|
media.bytes.into(),
|
|
media.content_type,
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
async fn frames_handler(
|
|
State(app): State<Arc<AppState>>,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Response {
|
|
if !check_internal_auth(&headers, &app.cfg.secret_key) {
|
|
return text(StatusCode::UNAUTHORIZED, "Unauthorized");
|
|
}
|
|
let body = match read_limited_body(request).await {
|
|
Ok(body) => body,
|
|
Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let req: FramesRequest = match serde_json::from_slice::<FramesRequest>(&body) {
|
|
Ok(req) if req.version.is_none_or(|v| v == 2) => req,
|
|
_ => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let req = req.into_metadata_request();
|
|
let input = match load_metadata_input(&app, &req).await {
|
|
Ok(input) => input,
|
|
Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
match media_process::extract_video_thumbnail(&input.data, AssetExtension::Jpeg) {
|
|
Ok(frame) => {
|
|
let encoded = general_purpose::STANDARD.encode(frame.bytes);
|
|
json_response(
|
|
StatusCode::OK,
|
|
format!(
|
|
"{{\"frames\":[{{\"timestamp\":0,\"mime_type\":\"image/jpeg\",\"base64\":\"{}\"}}]}}",
|
|
encoded
|
|
),
|
|
)
|
|
}
|
|
Err(_) => json_response(StatusCode::OK, "{\"frames\":[]}".to_owned()),
|
|
}
|
|
}
|
|
|
|
async fn relay_options() -> Response {
|
|
let mut response = Response::new(Body::empty());
|
|
*response.status_mut() = StatusCode::NO_CONTENT;
|
|
http_headers::add_security_headers(response.headers_mut());
|
|
relay_cors(response.headers_mut());
|
|
response.headers_mut().insert(
|
|
header::ACCESS_CONTROL_MAX_AGE,
|
|
HeaderValue::from_static("600"),
|
|
);
|
|
response
|
|
}
|
|
|
|
async fn relay_put(
|
|
State(app): State<Arc<AppState>>,
|
|
Path(key): Path<String>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> Response {
|
|
if app.cfg.mode != DeploymentMode::Upload {
|
|
return text(StatusCode::NOT_FOUND, "Not Found");
|
|
}
|
|
let key = external_path::percent_decode_string(&key, false);
|
|
let Some(token_raw) = params.get("t") else {
|
|
return relay_error(upload_relay::RelayError::MissingToken);
|
|
};
|
|
let token = match upload_relay::decode_token(
|
|
token_raw,
|
|
&app.cfg.upload_relay_secret,
|
|
upload_relay::now_unix(),
|
|
) {
|
|
Ok(token) => token,
|
|
Err(err) => return relay_error(upload_relay::map_token_error(err)),
|
|
};
|
|
let part_number =
|
|
match upload_relay::query_part_number(params.get("partNumber").map(String::as_str)) {
|
|
Ok(part_number) => part_number,
|
|
Err(err) => return relay_error(err),
|
|
};
|
|
let content_length = headers
|
|
.get(header::CONTENT_LENGTH)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|v| v.parse::<u64>().ok());
|
|
if let Err(err) = upload_relay::validate_relay_request(
|
|
&token,
|
|
upload_relay::RelayRequest {
|
|
uploads_bucket: &app.cfg.bucket_uploads,
|
|
request_key: &key,
|
|
request_method: request.method(),
|
|
query_upload_id: params.get("uploadId").map(String::as_str),
|
|
query_part_number: part_number,
|
|
content_length,
|
|
max_body_bytes: app.cfg.upload_relay_max_body_bytes,
|
|
},
|
|
) {
|
|
return relay_error(err);
|
|
}
|
|
let (body, body_length, client_failure) = match content_length {
|
|
Some(declared) => {
|
|
let (tx, rx) = tokio::sync::mpsc::channel(RELAY_STREAM_BUFFER_FRAMES);
|
|
let failure = Arc::new(OnceLock::new());
|
|
spawn_relay_body_feeder(request.into_body(), declared, tx, Arc::clone(&failure));
|
|
(RelayBody::Streamed(rx), declared, Some(failure))
|
|
}
|
|
None => {
|
|
let body_length_limit = token.mb.min(app.cfg.upload_relay_max_body_bytes);
|
|
let spooled = match spool_to_temp(
|
|
request.into_body(),
|
|
content_length,
|
|
body_length_limit,
|
|
&app.cfg.upload_relay_spool_dir,
|
|
app.cfg.upload_relay_spool_chunk_bytes,
|
|
app.cfg.upload_relay_spool_max_total_bytes,
|
|
)
|
|
.await
|
|
{
|
|
Ok(spooled) => spooled,
|
|
Err(SpoolError::PayloadTooLarge) => {
|
|
return relay_error(upload_relay::RelayError::PayloadTooLarge);
|
|
}
|
|
Err(SpoolError::PayloadShortRead) | Err(SpoolError::Body(_)) => {
|
|
return relay_error(upload_relay::RelayError::ClientUploadFailed);
|
|
}
|
|
Err(SpoolError::BudgetExhausted) => {
|
|
return relay_error(upload_relay::RelayError::UpstreamRetryable);
|
|
}
|
|
Err(SpoolError::Io(_)) => {
|
|
return relay_error(upload_relay::RelayError::InternalError);
|
|
}
|
|
};
|
|
let (file, spooled_length) = spooled.into_parts();
|
|
(RelayBody::Spooled(file), spooled_length, None)
|
|
}
|
|
};
|
|
let content_type = token.ct.clone().or_else(|| {
|
|
headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(ToOwned::to_owned)
|
|
});
|
|
let timeout_ms = if client_failure.is_some() {
|
|
app.cfg.upload_relay_s3_timeout_ms.saturating_add(
|
|
body_length
|
|
.div_ceil(RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC)
|
|
.saturating_mul(1000),
|
|
)
|
|
} else {
|
|
app.cfg.upload_relay_s3_timeout_ms
|
|
};
|
|
let options = RelayPutOptions {
|
|
body,
|
|
content_length: body_length,
|
|
content_type,
|
|
upload_id: params.get("uploadId").cloned(),
|
|
part_number,
|
|
timeout_ms,
|
|
};
|
|
match app
|
|
.store
|
|
.relay_put_object(&app.cfg.bucket_uploads, &key, options)
|
|
.await
|
|
{
|
|
Ok(etag) => {
|
|
let mut response = Response::new(Body::empty());
|
|
*response.status_mut() = StatusCode::OK;
|
|
http_headers::add_security_headers(response.headers_mut());
|
|
relay_cors(response.headers_mut());
|
|
if let Some(etag) = etag {
|
|
response.headers_mut().insert(
|
|
header::ETAG,
|
|
HeaderValue::from_str(&etag).unwrap_or_else(|_| HeaderValue::from_static("")),
|
|
);
|
|
}
|
|
response
|
|
}
|
|
Err(err) => {
|
|
if let Some(client_err) = client_failure.as_ref().and_then(|failure| failure.get()) {
|
|
return relay_error(*client_err);
|
|
}
|
|
warn!(error = %err, "upload relay upstream S3 PUT failed");
|
|
relay_error(upload_relay::RelayError::UpstreamS3Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
const RELAY_STREAM_BUFFER_FRAMES: usize = 8;
|
|
const RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC: u64 = 16 * 1024;
|
|
|
|
fn spawn_relay_body_feeder(
|
|
mut body: Body,
|
|
declared_length: u64,
|
|
tx: tokio::sync::mpsc::Sender<Result<Bytes, std::io::Error>>,
|
|
failure: Arc<OnceLock<upload_relay::RelayError>>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
let mut written: u64 = 0;
|
|
while let Some(frame_result) = body.frame().await {
|
|
let frame = match frame_result {
|
|
Ok(frame) => frame,
|
|
Err(_) => {
|
|
let _ = failure.set(upload_relay::RelayError::ClientUploadFailed);
|
|
let _ = tx
|
|
.send(Err(std::io::Error::new(
|
|
std::io::ErrorKind::ConnectionAborted,
|
|
"client body read failed",
|
|
)))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
let Ok(chunk) = frame.into_data() else {
|
|
continue;
|
|
};
|
|
if chunk.is_empty() {
|
|
continue;
|
|
}
|
|
let next = written.saturating_add(chunk.len() as u64);
|
|
if next > declared_length {
|
|
let _ = failure.set(upload_relay::RelayError::PayloadTooLarge);
|
|
let _ = tx
|
|
.send(Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidData,
|
|
"payload exceeded declared length",
|
|
)))
|
|
.await;
|
|
return;
|
|
}
|
|
written = next;
|
|
if tx.send(Ok(chunk)).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
if written != declared_length {
|
|
let _ = failure.set(upload_relay::RelayError::ClientUploadFailed);
|
|
let _ = tx
|
|
.send(Err(std::io::Error::new(
|
|
std::io::ErrorKind::UnexpectedEof,
|
|
"payload shorter than declared length",
|
|
)))
|
|
.await;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn catch_all(
|
|
State(app): State<Arc<AppState>>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
request: Request<Body>,
|
|
) -> Response {
|
|
let method = request.method().clone();
|
|
if method != Method::GET && method != Method::HEAD {
|
|
return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed");
|
|
}
|
|
let path = request.uri().path().to_owned();
|
|
if app.cfg.mode == DeploymentMode::Static {
|
|
let key = decode_storage_key(&path);
|
|
return serve_stored_raw(
|
|
&app,
|
|
method,
|
|
&app.cfg.bucket_static,
|
|
&key,
|
|
request.headers(),
|
|
)
|
|
.await;
|
|
}
|
|
if let Some(rest) = path.strip_prefix("/external/") {
|
|
return serve_external(&app, method, rest, ¶ms, request.headers()).await;
|
|
}
|
|
if path.starts_with("/attachments/") {
|
|
let key = decode_storage_key(&path);
|
|
return serve_attachment(&app, method, &key, ¶ms, request.headers()).await;
|
|
}
|
|
if path.starts_with("/themes/") && path.ends_with(".css") {
|
|
let key = decode_storage_key(&path);
|
|
return serve_stored_with_override(
|
|
&app,
|
|
method,
|
|
&app.cfg.bucket_cdn,
|
|
&key,
|
|
"text/css; charset=utf-8",
|
|
request.headers(),
|
|
)
|
|
.await;
|
|
}
|
|
if let Some(key) = parse_entrance_sound_path(&path) {
|
|
return serve_stored_raw(&app, method, &app.cfg.bucket_cdn, &key, request.headers()).await;
|
|
}
|
|
if let Some(asset) = parse_guild_member_asset_path(&path) {
|
|
return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await;
|
|
}
|
|
if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Emoji) {
|
|
return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await;
|
|
}
|
|
if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Sticker) {
|
|
return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await;
|
|
}
|
|
if let Some(asset) = parse_standard_asset_path(&path) {
|
|
return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await;
|
|
}
|
|
text(StatusCode::NOT_FOUND, "Not Found")
|
|
}
|
|
|
|
struct ParsedAssetPath {
|
|
storage_key: String,
|
|
original_ext: AssetExtension,
|
|
hash: String,
|
|
kind: AssetKind,
|
|
forced_output_format: Option<AssetExtension>,
|
|
}
|
|
|
|
fn parse_standard_asset_path(path: &str) -> Option<ParsedAssetPath> {
|
|
let mut parts = path.trim_start_matches('/').split('/');
|
|
let prefix = parts.next()?;
|
|
let id = parts.next()?;
|
|
let filename = parts.next()?;
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
let kind = match prefix {
|
|
"avatars" => AssetKind::Avatar,
|
|
"icons" => AssetKind::GuildIcon,
|
|
"branding" => AssetKind::GuildIcon,
|
|
"banners" => AssetKind::Banner,
|
|
"splashes" => AssetKind::Splash,
|
|
"embed-splashes" => AssetKind::EmbedSplash,
|
|
_ => return None,
|
|
};
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
let parsed = parse_asset_filename(filename)?;
|
|
let storage_hash = asset_hash::strip_animation_prefix(parsed.hash);
|
|
Some(ParsedAssetPath {
|
|
storage_key: format!("{prefix}/{id}/{storage_hash}"),
|
|
original_ext: parsed.ext,
|
|
hash: parsed.hash.to_owned(),
|
|
kind,
|
|
forced_output_format: None,
|
|
})
|
|
}
|
|
|
|
fn parse_guild_member_asset_path(path: &str) -> Option<ParsedAssetPath> {
|
|
let mut parts = path.trim_start_matches('/').split('/');
|
|
if parts.next()? != "guilds" {
|
|
return None;
|
|
}
|
|
let guild_id = parts.next()?;
|
|
if parts.next()? != "users" {
|
|
return None;
|
|
}
|
|
let user_id = parts.next()?;
|
|
let prefix = parts.next()?;
|
|
let filename = parts.next()?;
|
|
if guild_id.is_empty() || user_id.is_empty() || parts.next().is_some() {
|
|
return None;
|
|
}
|
|
let kind = match prefix {
|
|
"avatars" => AssetKind::Avatar,
|
|
"banners" => AssetKind::Banner,
|
|
_ => return None,
|
|
};
|
|
let parsed = parse_asset_filename(filename)?;
|
|
let storage_hash = asset_hash::strip_animation_prefix(parsed.hash);
|
|
Some(ParsedAssetPath {
|
|
storage_key: format!("guilds/{guild_id}/users/{user_id}/{prefix}/{storage_hash}"),
|
|
original_ext: parsed.ext,
|
|
hash: parsed.hash.to_owned(),
|
|
kind,
|
|
forced_output_format: None,
|
|
})
|
|
}
|
|
|
|
fn parse_simple_asset_path(path: &str, kind: AssetKind) -> Option<ParsedAssetPath> {
|
|
let expected_prefix = match kind {
|
|
AssetKind::Emoji => "emojis",
|
|
AssetKind::Sticker => "stickers",
|
|
_ => return None,
|
|
};
|
|
let mut parts = path.trim_start_matches('/').split('/');
|
|
let prefix = parts.next()?;
|
|
if prefix != expected_prefix {
|
|
return None;
|
|
}
|
|
let filename = parts.next()?;
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
let parsed = parse_asset_filename(filename)?;
|
|
let id_no_ext = filename.split_once('.')?.0;
|
|
if id_no_ext.is_empty() {
|
|
return None;
|
|
}
|
|
Some(ParsedAssetPath {
|
|
storage_key: format!("{prefix}/{id_no_ext}"),
|
|
original_ext: parsed.ext,
|
|
hash: parsed.hash.to_owned(),
|
|
kind,
|
|
forced_output_format: (kind == AssetKind::Sticker).then_some(AssetExtension::Webp),
|
|
})
|
|
}
|
|
|
|
fn parse_entrance_sound_path(path: &str) -> Option<String> {
|
|
let mut parts = path.trim_start_matches('/').split('/');
|
|
if parts.next()? != "entrance-sounds" {
|
|
return None;
|
|
}
|
|
let user_id = parts.next()?;
|
|
let filename = parts.next()?;
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
if user_id.is_empty() || !user_id.bytes().all(|b| b.is_ascii_digit()) {
|
|
return None;
|
|
}
|
|
let (hash, ext) = filename.split_once('.')?;
|
|
if hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
|
return None;
|
|
}
|
|
if !matches!(ext, "mp3" | "ogg" | "m4a" | "wav") {
|
|
return None;
|
|
}
|
|
Some(format!("entrance-sounds/{user_id}/{filename}"))
|
|
}
|
|
|
|
struct ParsedAssetFilename<'a> {
|
|
hash: &'a str,
|
|
ext: AssetExtension,
|
|
}
|
|
|
|
fn parse_asset_filename(filename: &str) -> Option<ParsedAssetFilename<'_>> {
|
|
let (hash, ext_raw) = filename.split_once('.')?;
|
|
if hash.is_empty() || ext_raw.is_empty() || ext_raw.contains('.') {
|
|
return None;
|
|
}
|
|
if !hash.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
|
return None;
|
|
}
|
|
if !ext_raw.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
|
return None;
|
|
}
|
|
Some(ParsedAssetFilename {
|
|
hash,
|
|
ext: AssetExtension::parse(ext_raw)?,
|
|
})
|
|
}
|
|
|
|
fn asset_filename_hint(asset: &ParsedAssetPath) -> String {
|
|
let hash = asset_hash::strip_animation_prefix(&asset.hash);
|
|
format!("{hash}.{}", asset.original_ext.name())
|
|
}
|
|
|
|
const EXTERNAL_SNIFF_PREFIX_BYTES: usize = 8192;
|
|
const EXTERNAL_HINT_CACHE_ENTRIES: u64 = 4096;
|
|
|
|
type ExternalHintCache = moka::sync::Cache<String, ExternalHint>;
|
|
|
|
#[derive(Clone)]
|
|
struct ExternalHint {
|
|
url: String,
|
|
content_type: String,
|
|
source_format: Option<AssetExtension>,
|
|
}
|
|
|
|
fn new_external_hint_cache(cfg: &Config) -> ExternalHintCache {
|
|
moka::sync::Cache::builder()
|
|
.max_capacity(EXTERNAL_HINT_CACHE_ENTRIES)
|
|
.time_to_live(Duration::from_millis(cfg.transform_cache_ttl_ms.max(1)))
|
|
.build()
|
|
}
|
|
|
|
fn external_sniffed_content_type(data: &[u8], filename: &str, content_type: String) -> String {
|
|
let prefix = &data[..data.len().min(EXTERNAL_SNIFF_PREFIX_BYTES)];
|
|
if mime::sniff(prefix).mime == "image/svg+xml" {
|
|
return "image/svg+xml".to_owned();
|
|
}
|
|
if content_type_is_trustworthy(&content_type) {
|
|
return content_type;
|
|
}
|
|
mime::detect(prefix, filename, Some(&content_type))
|
|
}
|
|
|
|
fn external_hint(url: &str, filename: &str, content_type: &str, data: &[u8]) -> ExternalHint {
|
|
let content_type = external_sniffed_content_type(data, filename, content_type.to_owned());
|
|
let source_format = if extension_from_mime(mime::sniff(data).mime) == Some(AssetExtension::Apng)
|
|
{
|
|
Some(AssetExtension::Apng)
|
|
} else {
|
|
extension_from_mime(&content_type).or_else(|| image_extension_from_filename(filename))
|
|
};
|
|
ExternalHint {
|
|
url: url.to_owned(),
|
|
content_type,
|
|
source_format,
|
|
}
|
|
}
|
|
|
|
fn external_cached_transform(
|
|
app: &Arc<AppState>,
|
|
url: &str,
|
|
method: &Method,
|
|
params: &HashMap<String, String>,
|
|
headers: &HeaderMap,
|
|
) -> Option<Response> {
|
|
let hint = app.external_hints.get(url)?;
|
|
let explicit_requested_format = explicit_output_format(params).ok()?;
|
|
let width = parse_optional_dimension_param(params, "width").ok()?;
|
|
let height = parse_optional_dimension_param(params, "height").ok()?;
|
|
let animated = animated_param(params, false);
|
|
let filename = url_filename(&hint.url);
|
|
let (format, quality, response_content_type) = match mime::category(&hint.content_type) {
|
|
Some(mime::Category::Video) => {
|
|
let format = output_format::coerce_unsupported_format(explicit_requested_format?);
|
|
let quality = params
|
|
.get("quality")
|
|
.cloned()
|
|
.unwrap_or_else(|| "high".to_owned());
|
|
(format, quality, format.mime().to_owned())
|
|
}
|
|
Some(mime::Category::Image) => {
|
|
let requested_format = explicit_requested_format.unwrap_or_else(|| {
|
|
external_default_output_extension(&filename, &hint.content_type)
|
|
});
|
|
let requested_supported_format =
|
|
output_format::coerce_unsupported_format(requested_format);
|
|
let format = effective_animated_image_output_format(
|
|
hint.source_format,
|
|
requested_supported_format,
|
|
animated,
|
|
);
|
|
let quality = params.get("quality").cloned().unwrap_or_else(|| {
|
|
default_transform_quality(
|
|
format,
|
|
animated,
|
|
transform_static_quality_default(hint.source_format),
|
|
)
|
|
.to_owned()
|
|
});
|
|
let response_content_type = transform_response_content_type(
|
|
explicit_requested_format,
|
|
requested_format,
|
|
format,
|
|
&hint.content_type,
|
|
)
|
|
.to_owned();
|
|
(format, quality, response_content_type)
|
|
}
|
|
_ => return None,
|
|
};
|
|
let cached = app
|
|
.transform_cache
|
|
.get(&transform_cache_key(TransformCacheKeyInput {
|
|
route: TransformRoute::External,
|
|
cache_identity: &hint.url,
|
|
width,
|
|
height,
|
|
format,
|
|
quality: &quality,
|
|
animated,
|
|
effort: None,
|
|
}))?;
|
|
metrics::GLOBAL
|
|
.transform_cache_hits
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let disposition = content_disposition_header(
|
|
&response_content_type,
|
|
bool_param(params, "download", false),
|
|
Some(&filename),
|
|
);
|
|
Some(media_response(
|
|
method.clone(),
|
|
cached,
|
|
&response_content_type,
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
Some(disposition),
|
|
))
|
|
}
|
|
|
|
async fn external_head_passthrough(
|
|
app: &AppState,
|
|
url: &str,
|
|
params: &HashMap<String, String>,
|
|
) -> Option<Response> {
|
|
let fetched = fetch_external_head(app, url).await.ok()?;
|
|
if fetched.status != StatusCode::OK || is_svg_content_type(&fetched.content_type) {
|
|
return None;
|
|
}
|
|
let total_len = usize::try_from(fetched.content_length?).ok()?;
|
|
if total_len > constants::MAX_MEDIA_PROXY_BYTES {
|
|
return None;
|
|
}
|
|
let filename = url_filename(&fetched.url);
|
|
let content_type = if content_type_is_trustworthy(&fetched.content_type) {
|
|
fetched.content_type
|
|
} else {
|
|
mime::detect(&[], &filename, Some(&fetched.content_type))
|
|
};
|
|
let disposition = content_disposition_header(
|
|
&content_type,
|
|
bool_param(params, "download", false),
|
|
Some(&filename),
|
|
);
|
|
Some(passthrough_head_response(
|
|
&content_type,
|
|
total_len,
|
|
None,
|
|
Some(disposition),
|
|
))
|
|
}
|
|
|
|
async fn serve_external(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
rest: &str,
|
|
params: &HashMap<String, String>,
|
|
headers: &HeaderMap,
|
|
) -> Response {
|
|
let Some((sig, proxy_path)) = rest.split_once('/') else {
|
|
return text(StatusCode::BAD_REQUEST, "Bad Request");
|
|
};
|
|
if !signing::verify_signature(proxy_path, sig, app.cfg.secret_key.as_bytes()) {
|
|
return text(StatusCode::UNAUTHORIZED, "Unauthorized");
|
|
}
|
|
let url = match external_path::reconstruct_original_url(proxy_path) {
|
|
Ok(url) => url,
|
|
Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let url_ext_is_svg =
|
|
image_extension_from_filename(&url_filename(&url)) == Some(AssetExtension::Svg);
|
|
let wants_transform = url_ext_is_svg
|
|
|| params.contains_key("width")
|
|
|| params.contains_key("height")
|
|
|| params.contains_key("format")
|
|
|| params.contains_key("quality")
|
|
|| animated_param(params, false);
|
|
let client_range = headers
|
|
.get(header::RANGE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|raw| raw.strip_prefix("bytes="))
|
|
.filter(|rv| !rv.is_empty() && rv.bytes().all(|b| b.is_ascii_graphic()));
|
|
let forward_range = if wants_transform { None } else { client_range };
|
|
let allow_stream = !wants_transform;
|
|
if wants_transform
|
|
&& let Some(cached) = external_cached_transform(app, &url, &method, params, headers)
|
|
{
|
|
return cached;
|
|
}
|
|
if method == Method::HEAD
|
|
&& !wants_transform
|
|
&& client_range.is_none()
|
|
&& let Some(response) = external_head_passthrough(app, &url, params).await
|
|
{
|
|
return response;
|
|
}
|
|
let mut fetched = match fetch_external_with_range(app, &url, forward_range, allow_stream).await
|
|
{
|
|
Ok(fetched) if fetched.status.is_success() => fetched,
|
|
Ok(fetched) => {
|
|
return text_with_source(
|
|
map_upstream_status(fetched.status),
|
|
"Upstream fetch failed",
|
|
"external_upstream_status",
|
|
format!("url={url} upstream_status={}", fetched.status.as_u16()),
|
|
);
|
|
}
|
|
Err(ExternalFetchError::BlockedUrl) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"external_blocked_url",
|
|
&url,
|
|
);
|
|
}
|
|
Err(ExternalFetchError::PayloadTooLarge) => {
|
|
return text_with_source(
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
"Payload Too Large",
|
|
"external_payload_too_large",
|
|
&url,
|
|
);
|
|
}
|
|
Err(err @ ExternalFetchError::TooManyRedirects)
|
|
| Err(err @ ExternalFetchError::FetchFailed) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_GATEWAY,
|
|
"Bad Gateway",
|
|
"external_fetch_failed",
|
|
format!("url={url} err={err:?}"),
|
|
);
|
|
}
|
|
};
|
|
if forward_range.is_some()
|
|
&& fetched.status == StatusCode::PARTIAL_CONTENT
|
|
&& is_svg_content_type(&fetched.content_type)
|
|
{
|
|
fetched = match fetch_external_with_range(app, &url, None, false).await {
|
|
Ok(fetched) if fetched.status.is_success() => fetched,
|
|
Ok(fetched) => {
|
|
return text_with_source(
|
|
map_upstream_status(fetched.status),
|
|
"Upstream fetch failed",
|
|
"external_upstream_status",
|
|
format!("url={url} upstream_status={}", fetched.status.as_u16()),
|
|
);
|
|
}
|
|
Err(ExternalFetchError::BlockedUrl) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"external_blocked_url",
|
|
&url,
|
|
);
|
|
}
|
|
Err(ExternalFetchError::PayloadTooLarge) => {
|
|
return text_with_source(
|
|
StatusCode::PAYLOAD_TOO_LARGE,
|
|
"Payload Too Large",
|
|
"external_payload_too_large",
|
|
&url,
|
|
);
|
|
}
|
|
Err(err @ ExternalFetchError::TooManyRedirects)
|
|
| Err(err @ ExternalFetchError::FetchFailed) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_GATEWAY,
|
|
"Bad Gateway",
|
|
"external_fetch_failed",
|
|
format!("url={url} err={err:?}"),
|
|
);
|
|
}
|
|
};
|
|
}
|
|
let filename = url_filename(&fetched.url);
|
|
let requested_download = bool_param(params, "download", false);
|
|
if forward_range.is_some() && fetched.status == StatusCode::PARTIAL_CONTENT {
|
|
let disposition =
|
|
content_disposition_header(&fetched.content_type, requested_download, Some(&filename));
|
|
return external_partial_response(method, fetched, Some(disposition));
|
|
}
|
|
let FetchedExternal {
|
|
url: fetched_url,
|
|
body,
|
|
content_type,
|
|
content_length,
|
|
..
|
|
} = fetched;
|
|
let data = match body {
|
|
ExternalBody::Streaming { response, prefix } => {
|
|
let content_type = external_sniffed_content_type(&prefix, &filename, content_type);
|
|
let disposition =
|
|
content_disposition_header(&content_type, requested_download, Some(&filename));
|
|
return external_streaming_response(
|
|
method,
|
|
response,
|
|
prefix,
|
|
content_length,
|
|
&content_type,
|
|
Some(disposition),
|
|
&fetched_url,
|
|
);
|
|
}
|
|
ExternalBody::Buffered(data) => data,
|
|
};
|
|
app.external_hints.insert(
|
|
url,
|
|
external_hint(&fetched_url, &filename, &content_type, &data),
|
|
);
|
|
serve_bytes_or_transform(
|
|
app,
|
|
ServeBytesRequest {
|
|
method,
|
|
data,
|
|
content_type,
|
|
cache_identity: &fetched_url,
|
|
filename: &filename,
|
|
route: TransformRoute::External,
|
|
params,
|
|
headers,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
struct FetchedExternal {
|
|
url: String,
|
|
status: StatusCode,
|
|
body: ExternalBody,
|
|
content_type: String,
|
|
content_length: Option<u64>,
|
|
content_range: Option<String>,
|
|
}
|
|
|
|
enum ExternalBody {
|
|
Buffered(Bytes),
|
|
Streaming {
|
|
response: reqwest::Response,
|
|
prefix: Bytes,
|
|
},
|
|
}
|
|
|
|
impl ExternalBody {
|
|
async fn into_buffered(self, url: &str) -> Result<Bytes, ExternalFetchError> {
|
|
match self {
|
|
Self::Buffered(data) => Ok(data),
|
|
Self::Streaming { response, prefix } => {
|
|
buffer_external_response(response, prefix, url).await
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn external_should_stream(allow_stream: bool, content_type: &str) -> bool {
|
|
allow_stream && !is_svg_content_type(content_type)
|
|
}
|
|
|
|
fn external_source_is_svg(prefix: &[u8], filename: &str, content_type: &str) -> bool {
|
|
image_extension_from_filename(filename) == Some(AssetExtension::Svg)
|
|
|| is_svg_content_type(&external_sniffed_content_type(
|
|
prefix,
|
|
filename,
|
|
content_type.to_owned(),
|
|
))
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ExternalStreamOverrun;
|
|
|
|
impl std::fmt::Display for ExternalStreamOverrun {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str("external stream exceeded the media proxy size cap")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ExternalStreamOverrun {}
|
|
|
|
struct ExternalStreamBody {
|
|
url: String,
|
|
prefix: Option<Bytes>,
|
|
upstream: reqwest::Body,
|
|
remaining: usize,
|
|
}
|
|
|
|
impl http_body::Body for ExternalStreamBody {
|
|
type Data = Bytes;
|
|
type Error = Box<dyn std::error::Error + Send + Sync>;
|
|
|
|
fn poll_frame(
|
|
self: Pin<&mut Self>,
|
|
cx: &mut Context<'_>,
|
|
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
|
let this = self.get_mut();
|
|
if let Some(prefix) = this.prefix.take()
|
|
&& !prefix.is_empty()
|
|
{
|
|
return Poll::Ready(Some(Ok(Frame::data(prefix))));
|
|
}
|
|
let frame = match Pin::new(&mut this.upstream).poll_frame(cx) {
|
|
Poll::Pending => return Poll::Pending,
|
|
Poll::Ready(None) => return Poll::Ready(None),
|
|
Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err.into()))),
|
|
Poll::Ready(Some(Ok(frame))) => frame,
|
|
};
|
|
let Some(len) = frame.data_ref().map(Bytes::len) else {
|
|
return Poll::Ready(Some(Ok(frame)));
|
|
};
|
|
let Some(remaining) = this.remaining.checked_sub(len) else {
|
|
this.remaining = 0;
|
|
warn!(url = %this.url, "external stream exceeded the size cap");
|
|
metrics::GLOBAL
|
|
.external_stream_overruns
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return Poll::Ready(Some(Err(Box::new(ExternalStreamOverrun))));
|
|
};
|
|
this.remaining = remaining;
|
|
Poll::Ready(Some(Ok(frame)))
|
|
}
|
|
}
|
|
|
|
fn external_stream_body(
|
|
method: &Method,
|
|
response: reqwest::Response,
|
|
prefix: Bytes,
|
|
url: &str,
|
|
) -> Body {
|
|
if *method == Method::HEAD {
|
|
return Body::empty();
|
|
}
|
|
let remaining = constants::MAX_MEDIA_PROXY_BYTES.saturating_sub(prefix.len());
|
|
let (_, upstream) = http::Response::from(response).into_parts();
|
|
Body::new(ExternalStreamBody {
|
|
url: url.to_owned(),
|
|
prefix: Some(prefix),
|
|
upstream,
|
|
remaining,
|
|
})
|
|
}
|
|
|
|
fn external_partial_response(
|
|
method: Method,
|
|
fetched: FetchedExternal,
|
|
disposition: Option<String>,
|
|
) -> Response {
|
|
let FetchedExternal {
|
|
url,
|
|
body,
|
|
content_type,
|
|
content_length,
|
|
content_range,
|
|
..
|
|
} = fetched;
|
|
let (body, body_len) = match body {
|
|
ExternalBody::Buffered(data) => {
|
|
let body_len = Some(data.len() as u64);
|
|
let body = if method == Method::HEAD {
|
|
Body::empty()
|
|
} else {
|
|
Body::from(data)
|
|
};
|
|
(body, body_len)
|
|
}
|
|
ExternalBody::Streaming { response, prefix } => (
|
|
external_stream_body(&method, response, prefix, &url),
|
|
content_length,
|
|
),
|
|
};
|
|
let mut response = Response::new(body);
|
|
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
|
|
http_headers::add_media_headers(
|
|
response.headers_mut(),
|
|
body_len
|
|
.and_then(|len| usize::try_from(len).ok())
|
|
.unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
|
&content_type,
|
|
None,
|
|
);
|
|
if let Some(body_len) = body_len {
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_len));
|
|
}
|
|
if let Some(cr) = content_range.as_deref()
|
|
&& let Ok(value) = HeaderValue::from_str(cr)
|
|
{
|
|
response.headers_mut().insert(header::CONTENT_RANGE, value);
|
|
}
|
|
if let Some(disposition) = disposition
|
|
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
|
{
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_DISPOSITION, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
fn external_streaming_response(
|
|
method: Method,
|
|
response: reqwest::Response,
|
|
prefix: Bytes,
|
|
content_length: Option<u64>,
|
|
content_type: &str,
|
|
disposition: Option<String>,
|
|
url: &str,
|
|
) -> Response {
|
|
let body = external_stream_body(&method, response, prefix, url);
|
|
let mut http_response = Response::new(body);
|
|
*http_response.status_mut() = StatusCode::OK;
|
|
http_headers::add_media_headers(
|
|
http_response.headers_mut(),
|
|
content_length
|
|
.and_then(|len| usize::try_from(len).ok())
|
|
.unwrap_or(constants::MAX_MEDIA_PROXY_BYTES),
|
|
content_type,
|
|
None,
|
|
);
|
|
if let Some(content_length) = content_length {
|
|
http_response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(content_length));
|
|
}
|
|
if let Some(disposition) = disposition
|
|
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
|
{
|
|
http_response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_DISPOSITION, value);
|
|
}
|
|
http_response
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum ExternalFetchError {
|
|
BlockedUrl,
|
|
PayloadTooLarge,
|
|
TooManyRedirects,
|
|
FetchFailed,
|
|
}
|
|
|
|
async fn fetch_external(app: &AppState, url: &str) -> Result<FetchedExternal, ExternalFetchError> {
|
|
fetch_external_with_range(app, url, None, false).await
|
|
}
|
|
|
|
async fn fetch_external_head(
|
|
app: &AppState,
|
|
url: &str,
|
|
) -> Result<FetchedExternal, ExternalFetchError> {
|
|
fetch_external_timed(app, url, Method::HEAD, None, false).await
|
|
}
|
|
|
|
async fn fetch_external_with_range(
|
|
app: &AppState,
|
|
url: &str,
|
|
range: Option<&str>,
|
|
allow_stream: bool,
|
|
) -> Result<FetchedExternal, ExternalFetchError> {
|
|
fetch_external_timed(app, url, Method::GET, range, allow_stream).await
|
|
}
|
|
|
|
async fn fetch_external_timed(
|
|
app: &AppState,
|
|
url: &str,
|
|
method: Method,
|
|
range: Option<&str>,
|
|
allow_stream: bool,
|
|
) -> Result<FetchedExternal, ExternalFetchError> {
|
|
let start_ms = metrics::now_ms();
|
|
let result = fetch_external_inner(app, url, method, range, allow_stream).await;
|
|
request_log::record_stage(Stage::Fetch, (metrics::now_ms() - start_ms).max(0) as u64);
|
|
result
|
|
}
|
|
|
|
async fn fetch_external_inner(
|
|
app: &AppState,
|
|
url: &str,
|
|
method: Method,
|
|
range: Option<&str>,
|
|
allow_stream: bool,
|
|
) -> Result<FetchedExternal, ExternalFetchError> {
|
|
let mut current_url = url.to_owned();
|
|
let mut visited: Vec<String> = Vec::new();
|
|
for _ in 0..=5 {
|
|
if visited.iter().any(|seen| seen == ¤t_url) {
|
|
warn!(url = %current_url, "redirect loop detected");
|
|
return Err(ExternalFetchError::TooManyRedirects);
|
|
}
|
|
visited.push(current_url.clone());
|
|
if let Err(err) = public_net_policy::validate_url(¤t_url) {
|
|
warn!(?err, url = %current_url, "blocked external fetch");
|
|
metrics::GLOBAL
|
|
.blocked_url_attempts
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return Err(ExternalFetchError::BlockedUrl);
|
|
}
|
|
let mut request = app.client.request(method.clone(), ¤t_url);
|
|
if let Some(rv) = range {
|
|
request = request.header(header::RANGE, format!("bytes={rv}"));
|
|
}
|
|
let response = request.send().await.map_err(|err| {
|
|
warn!(url = %current_url, %err, "external send failed");
|
|
ExternalFetchError::FetchFailed
|
|
})?;
|
|
let status = response.status();
|
|
if is_redirect_status(status) {
|
|
let Some(location) = response
|
|
.headers()
|
|
.get(header::LOCATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
warn!(url = %current_url, status = status.as_u16(), "redirect missing Location");
|
|
return Err(ExternalFetchError::FetchFailed);
|
|
};
|
|
current_url =
|
|
public_net_policy::resolve_redirect(¤t_url, location).map_err(|err| {
|
|
warn!(url = %current_url, %location, ?err, "redirect target blocked");
|
|
ExternalFetchError::BlockedUrl
|
|
})?;
|
|
continue;
|
|
}
|
|
return external_fetched_from_response(response, current_url, allow_stream).await;
|
|
}
|
|
Err(ExternalFetchError::TooManyRedirects)
|
|
}
|
|
|
|
async fn external_fetched_from_response(
|
|
mut response: reqwest::Response,
|
|
url: String,
|
|
allow_stream: bool,
|
|
) -> Result<FetchedExternal, ExternalFetchError> {
|
|
let status = response.status();
|
|
let content_type = response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("application/octet-stream")
|
|
.to_owned();
|
|
let content_range = response
|
|
.headers()
|
|
.get(header::CONTENT_RANGE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned);
|
|
let content_length = response
|
|
.headers()
|
|
.get(header::CONTENT_LENGTH)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.or_else(|| response.content_length());
|
|
if let Some(len) = content_length
|
|
&& len > constants::MAX_MEDIA_PROXY_BYTES as u64
|
|
{
|
|
warn!(url = %url, len, "external payload too large");
|
|
return Err(ExternalFetchError::PayloadTooLarge);
|
|
}
|
|
let mut prefix = Bytes::new();
|
|
if status.is_success() && external_should_stream(allow_stream, &content_type) {
|
|
prefix = external_body_prefix(&mut response, &url).await?;
|
|
if !external_source_is_svg(&prefix, &url_filename(&url), &content_type) {
|
|
return Ok(FetchedExternal {
|
|
url,
|
|
status,
|
|
body: ExternalBody::Streaming { response, prefix },
|
|
content_type,
|
|
content_length,
|
|
content_range,
|
|
});
|
|
}
|
|
}
|
|
let data = buffer_external_response(response, prefix, &url).await?;
|
|
Ok(FetchedExternal {
|
|
url,
|
|
status,
|
|
body: ExternalBody::Buffered(data),
|
|
content_type,
|
|
content_length,
|
|
content_range,
|
|
})
|
|
}
|
|
|
|
async fn external_body_prefix(
|
|
response: &mut reqwest::Response,
|
|
url: &str,
|
|
) -> Result<Bytes, ExternalFetchError> {
|
|
let mut prefix: Vec<u8> = Vec::new();
|
|
while prefix.len() < EXTERNAL_SNIFF_PREFIX_BYTES {
|
|
let chunk = response.chunk().await.map_err(|err| {
|
|
warn!(url = %url, %err, "external body read failed");
|
|
ExternalFetchError::FetchFailed
|
|
})?;
|
|
let Some(chunk) = chunk else {
|
|
break;
|
|
};
|
|
prefix.extend_from_slice(&chunk);
|
|
}
|
|
Ok(Bytes::from(prefix))
|
|
}
|
|
|
|
async fn buffer_external_response(
|
|
mut response: reqwest::Response,
|
|
prefix: Bytes,
|
|
url: &str,
|
|
) -> Result<Bytes, ExternalFetchError> {
|
|
let initial_capacity = response
|
|
.content_length()
|
|
.map(|len| len.min(constants::MAX_MEDIA_PROXY_BYTES as u64) as usize)
|
|
.unwrap_or(0)
|
|
.saturating_add(prefix.len())
|
|
.min(constants::MAX_MEDIA_PROXY_BYTES);
|
|
let mut buf: Vec<u8> = Vec::with_capacity(initial_capacity);
|
|
buf.extend_from_slice(&prefix);
|
|
while let Some(chunk) = response.chunk().await.map_err(|err| {
|
|
warn!(url = %url, %err, "external body read failed");
|
|
ExternalFetchError::FetchFailed
|
|
})? {
|
|
let Some(next_len) = buf
|
|
.len()
|
|
.checked_add(chunk.len())
|
|
.filter(|len| *len <= constants::MAX_MEDIA_PROXY_BYTES)
|
|
else {
|
|
warn!(url = %url, "external payload too large");
|
|
return Err(ExternalFetchError::PayloadTooLarge);
|
|
};
|
|
debug_assert!(next_len <= constants::MAX_MEDIA_PROXY_BYTES);
|
|
buf.extend_from_slice(&chunk);
|
|
}
|
|
Ok(Bytes::from(buf))
|
|
}
|
|
|
|
fn map_upstream_status(status: StatusCode) -> StatusCode {
|
|
match status.as_u16() {
|
|
400 => StatusCode::BAD_REQUEST,
|
|
401 => StatusCode::UNAUTHORIZED,
|
|
403 => StatusCode::FORBIDDEN,
|
|
404 => StatusCode::NOT_FOUND,
|
|
405 => StatusCode::METHOD_NOT_ALLOWED,
|
|
406 => StatusCode::NOT_ACCEPTABLE,
|
|
408 => StatusCode::REQUEST_TIMEOUT,
|
|
409 => StatusCode::CONFLICT,
|
|
410 => StatusCode::GONE,
|
|
411 => StatusCode::LENGTH_REQUIRED,
|
|
412 => StatusCode::PRECONDITION_FAILED,
|
|
413 => StatusCode::PAYLOAD_TOO_LARGE,
|
|
414 => StatusCode::URI_TOO_LONG,
|
|
415 => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
416 => StatusCode::RANGE_NOT_SATISFIABLE,
|
|
428 => StatusCode::from_u16(428).expect("428 is a valid status code"),
|
|
429 => StatusCode::TOO_MANY_REQUESTS,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
}
|
|
}
|
|
|
|
fn map_internal_metadata_upstream_status(status: StatusCode) -> StatusCode {
|
|
match status.as_u16() {
|
|
429 => StatusCode::SERVICE_UNAVAILABLE,
|
|
_ => map_upstream_status(status),
|
|
}
|
|
}
|
|
|
|
fn is_redirect_status(status: StatusCode) -> bool {
|
|
matches!(
|
|
status,
|
|
StatusCode::MOVED_PERMANENTLY
|
|
| StatusCode::FOUND
|
|
| StatusCode::SEE_OTHER
|
|
| StatusCode::TEMPORARY_REDIRECT
|
|
| StatusCode::PERMANENT_REDIRECT
|
|
)
|
|
}
|
|
|
|
async fn serve_asset_image(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
asset: ParsedAssetPath,
|
|
params: &HashMap<String, String>,
|
|
headers: &HeaderMap,
|
|
) -> Response {
|
|
let requested_download = bool_param(params, "download", false);
|
|
let asset_filename = asset_filename_hint(&asset);
|
|
let size = constants::parse_image_size(params.get("size").map(String::as_str));
|
|
let selected = output_format::select_url_variant(output_format::Input {
|
|
kind: asset.kind,
|
|
original: asset.original_ext,
|
|
requested_size: Some(size),
|
|
manual_format_override: asset
|
|
.forced_output_format
|
|
.or_else(|| asset_manual_format_override(params, asset.original_ext)),
|
|
});
|
|
let animated = asset_wants_animated(params, &asset.hash);
|
|
let object =
|
|
match read_cdn_object_with_fallback(app, &asset.storage_key, asset.original_ext).await {
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(&asset.storage_key, err),
|
|
};
|
|
let sniffed_source = mime::sniff(&object.data);
|
|
let sniffed_source_ext = extension_from_mime(sniffed_source.mime);
|
|
let source_format = if sniffed_source_ext == Some(AssetExtension::Apng) {
|
|
AssetExtension::Apng
|
|
} else {
|
|
extension_from_mime(&object.content_type)
|
|
.or(sniffed_source_ext)
|
|
.unwrap_or(asset.original_ext)
|
|
};
|
|
let serve_content_type = if object.content_type.is_empty()
|
|
|| object
|
|
.content_type
|
|
.eq_ignore_ascii_case("application/octet-stream")
|
|
|| extension_from_mime(&object.content_type).is_none()
|
|
{
|
|
source_format.mime().to_owned()
|
|
} else {
|
|
object.content_type.clone()
|
|
};
|
|
let out_ext =
|
|
effective_animated_image_output_format(Some(source_format), selected.format, animated);
|
|
let quality = params
|
|
.get("quality")
|
|
.cloned()
|
|
.unwrap_or_else(|| default_transform_quality(out_ext, animated, "high").to_owned());
|
|
let width = selected.size;
|
|
let height = selected.size;
|
|
if same_format_loaded_image_request_can_use_original(
|
|
sniffed_source,
|
|
OriginalImageRequest {
|
|
source_ext: Some(source_format),
|
|
explicit_out_ext: asset_manual_format_override(params, asset.original_ext),
|
|
out_ext,
|
|
width,
|
|
height,
|
|
has_quality: params.contains_key("quality"),
|
|
effort: None,
|
|
animated,
|
|
},
|
|
) {
|
|
return media_response(
|
|
method,
|
|
object.data,
|
|
&serve_content_type,
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
Some(content_disposition_header(
|
|
&serve_content_type,
|
|
requested_download,
|
|
Some(&asset_filename),
|
|
)),
|
|
);
|
|
}
|
|
let options = media_process::ImageOptions {
|
|
width,
|
|
height,
|
|
format: out_ext,
|
|
quality: quality.clone(),
|
|
animated,
|
|
effort_override: None,
|
|
cover_crop: matches!(asset.kind, AssetKind::Emoji | AssetKind::Sticker),
|
|
deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64),
|
|
max_encode_frames: Some(app.cfg.max_encode_frames),
|
|
max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms),
|
|
};
|
|
let cache_key = asset_transform_cache_key(
|
|
&asset.storage_key,
|
|
selected.size,
|
|
out_ext,
|
|
&quality,
|
|
animated,
|
|
);
|
|
if let Some(cached) = app.transform_cache.get(&cache_key) {
|
|
metrics::GLOBAL
|
|
.transform_cache_hits
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return media_response(
|
|
method,
|
|
cached,
|
|
out_ext.mime(),
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
Some(content_disposition_header(
|
|
out_ext.mime(),
|
|
requested_download,
|
|
Some(&asset_filename),
|
|
)),
|
|
);
|
|
}
|
|
metrics::GLOBAL
|
|
.transform_cache_misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let coalescer_deadline = deadline_instant(options.deadline_ms);
|
|
let transformed = match app
|
|
.coalescer
|
|
.run_once_until(cache_key.clone(), coalescer_deadline, || {
|
|
let app = app.clone();
|
|
let data = object.data.clone();
|
|
let options = options.clone();
|
|
async move {
|
|
coalesced_work_result(run_transform(&app, data, options).await)
|
|
.map(|media| media.bytes)
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(bytes) => bytes,
|
|
Err(CoalescerError::RequestTimeout) => {
|
|
return text_with_reason(
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
"Gateway Timeout",
|
|
"coalescer_timeout_asset_image",
|
|
);
|
|
}
|
|
Err(CoalescerError::WorkFailed) => {
|
|
let src_ct = object.content_type.as_str();
|
|
let src_is_displayable = src_ct.starts_with("image/")
|
|
&& src_ct != "image/avif"
|
|
&& src_ct != "image/heic"
|
|
&& src_ct != "image/heif"
|
|
&& source_format != AssetExtension::Svg
|
|
&& !is_svg_content_type(src_ct);
|
|
if !src_is_displayable {
|
|
return text_with_source(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Transcode Failed",
|
|
"transcode_failed",
|
|
format!(
|
|
"asset key={} src_ct={} out={} size={:?} animated={}",
|
|
asset.storage_key,
|
|
src_ct,
|
|
out_ext.name(),
|
|
selected.size,
|
|
animated,
|
|
),
|
|
);
|
|
}
|
|
return media_response(
|
|
method,
|
|
object.data,
|
|
&object.content_type,
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
None,
|
|
);
|
|
}
|
|
};
|
|
app.transform_cache.put(cache_key, transformed.clone());
|
|
media_response(
|
|
method,
|
|
transformed,
|
|
out_ext.mime(),
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
Some(content_disposition_header(
|
|
out_ext.mime(),
|
|
requested_download,
|
|
Some(&asset_filename),
|
|
)),
|
|
)
|
|
}
|
|
|
|
async fn read_cdn_object_with_fallback(
|
|
app: &Arc<AppState>,
|
|
key: &str,
|
|
original_ext: AssetExtension,
|
|
) -> Result<crate::storage::Object, StorageError> {
|
|
match app.store.read_object(&app.cfg.bucket_cdn, key).await {
|
|
Ok(object) => Ok(object),
|
|
Err(StorageError::NotFound) => {
|
|
let fallback_key = format!("{key}.{}", original_ext.name());
|
|
app.store
|
|
.read_object(&app.cfg.bucket_cdn, &fallback_key)
|
|
.await
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
}
|
|
|
|
async fn serve_attachment(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
key: &str,
|
|
params: &HashMap<String, String>,
|
|
headers: &HeaderMap,
|
|
) -> Response {
|
|
let filename = filename_from_storage_key(key);
|
|
let animated = animated_param(params, false);
|
|
let wants_transform = params.contains_key("width")
|
|
|| params.contains_key("height")
|
|
|| params.contains_key("format")
|
|
|| params.contains_key("quality")
|
|
|| animated;
|
|
if !wants_transform {
|
|
return serve_stored_passthrough_stream(
|
|
app,
|
|
method,
|
|
&app.cfg.bucket_cdn,
|
|
key,
|
|
headers,
|
|
PassthroughDisposition::Attachment {
|
|
requested_download: bool_param(params, "download", false),
|
|
filename,
|
|
},
|
|
)
|
|
.await;
|
|
}
|
|
let object = match app.store.read_object(&app.cfg.bucket_cdn, key).await {
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
serve_bytes_or_transform(
|
|
app,
|
|
ServeBytesRequest {
|
|
method,
|
|
data: object.data,
|
|
content_type: object.content_type,
|
|
cache_identity: key,
|
|
filename,
|
|
route: TransformRoute::Attachment,
|
|
params,
|
|
headers,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn serve_stored_raw(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
bucket: &str,
|
|
key: &str,
|
|
headers: &HeaderMap,
|
|
) -> Response {
|
|
serve_stored_passthrough_stream(
|
|
app,
|
|
method,
|
|
bucket,
|
|
key,
|
|
headers,
|
|
PassthroughDisposition::None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
enum PassthroughDisposition<'a> {
|
|
None,
|
|
Attachment {
|
|
requested_download: bool,
|
|
filename: &'a str,
|
|
},
|
|
}
|
|
|
|
async fn serve_stored_passthrough_stream(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
bucket: &str,
|
|
key: &str,
|
|
headers: &HeaderMap,
|
|
disposition: PassthroughDisposition<'_>,
|
|
) -> Response {
|
|
if method == Method::HEAD {
|
|
return serve_stored_passthrough_head(app, bucket, key, headers, &disposition).await;
|
|
}
|
|
if app.cfg.mode == DeploymentMode::Mp
|
|
&& image_extension_from_filename(key) == Some(AssetExtension::Svg)
|
|
{
|
|
return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await;
|
|
}
|
|
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
|
let forwarded_range = match range::classify_request_range(range_header) {
|
|
range::RequestRange::Absent => None,
|
|
range::RequestRange::Forwardable(value) => Some(value),
|
|
range::RequestRange::Unsatisfiable => {
|
|
return passthrough_unsatisfiable_response(app, bucket, key, None).await;
|
|
}
|
|
};
|
|
let object = match app.store.stream_object(bucket, key, forwarded_range).await {
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
if object.status == StatusCode::RANGE_NOT_SATISFIABLE {
|
|
return passthrough_unsatisfiable_response(app, bucket, key, object.total_length).await;
|
|
}
|
|
let content_type = passthrough_content_type(&object.content_type, key);
|
|
if app.cfg.mode == DeploymentMode::Mp && is_svg_content_type(&content_type) {
|
|
return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await;
|
|
}
|
|
let total_len = match passthrough_total_len(app, bucket, key, object.total_length).await {
|
|
Ok(value) => value,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
if total_len > constants::MAX_MEDIA_PROXY_BYTES {
|
|
return storage_error_response(key, StorageError::StreamTooLong);
|
|
}
|
|
streaming_media_response(
|
|
method,
|
|
object,
|
|
total_len,
|
|
&content_type,
|
|
passthrough_disposition_header(&disposition, &content_type),
|
|
)
|
|
}
|
|
|
|
async fn serve_stored_passthrough_head(
|
|
app: &Arc<AppState>,
|
|
bucket: &str,
|
|
key: &str,
|
|
headers: &HeaderMap,
|
|
disposition: &PassthroughDisposition<'_>,
|
|
) -> Response {
|
|
let head = match app.store.head_object(bucket, key).await {
|
|
Ok(head) => head,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
if head.content_length > constants::MAX_MEDIA_PROXY_BYTES as u64 {
|
|
return storage_error_response(key, StorageError::StreamTooLong);
|
|
}
|
|
let content_type = passthrough_content_type(&head.content_type, key);
|
|
if app.cfg.mode == DeploymentMode::Mp
|
|
&& (is_svg_content_type(&content_type)
|
|
|| image_extension_from_filename(key) == Some(AssetExtension::Svg))
|
|
{
|
|
return serve_stored_passthrough_svg(app, Method::HEAD, bucket, key, headers, disposition)
|
|
.await;
|
|
}
|
|
let total_len = match usize::try_from(head.content_length) {
|
|
Ok(value) => value,
|
|
Err(_) => return storage_error_response(key, StorageError::StreamTooLong),
|
|
};
|
|
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
|
let parsed_range = range::parse_range(range_header, total_len);
|
|
if parsed_range.unsatisfiable {
|
|
return unsatisfiable_response(total_len);
|
|
}
|
|
passthrough_head_response(
|
|
&content_type,
|
|
total_len,
|
|
parsed_range.range,
|
|
passthrough_disposition_header(disposition, &content_type),
|
|
)
|
|
}
|
|
|
|
async fn serve_stored_passthrough_svg(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
bucket: &str,
|
|
key: &str,
|
|
headers: &HeaderMap,
|
|
disposition: &PassthroughDisposition<'_>,
|
|
) -> Response {
|
|
let object = match app.store.read_object(bucket, key).await {
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
let cache_identity = format!("{bucket}/{key}");
|
|
serve_stored_svg_rasterized(
|
|
app,
|
|
method,
|
|
object.data,
|
|
&cache_identity,
|
|
headers,
|
|
disposition,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn passthrough_total_len(
|
|
app: &Arc<AppState>,
|
|
bucket: &str,
|
|
key: &str,
|
|
known: Option<u64>,
|
|
) -> Result<usize, StorageError> {
|
|
let total = match known {
|
|
Some(value) => value,
|
|
None => app.store.head_object(bucket, key).await?.content_length,
|
|
};
|
|
usize::try_from(total).map_err(|_| StorageError::StreamTooLong)
|
|
}
|
|
|
|
async fn passthrough_unsatisfiable_response(
|
|
app: &Arc<AppState>,
|
|
bucket: &str,
|
|
key: &str,
|
|
known_total: Option<u64>,
|
|
) -> Response {
|
|
match passthrough_total_len(app, bucket, key, known_total).await {
|
|
Ok(total_len) => unsatisfiable_response(total_len),
|
|
Err(err) => storage_error_response(key, err),
|
|
}
|
|
}
|
|
|
|
fn unsatisfiable_response(total_len: usize) -> Response {
|
|
let mut response = Response::new(Body::empty());
|
|
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
|
http_headers::add_unsatisfiable_headers(response.headers_mut(), total_len);
|
|
response
|
|
}
|
|
|
|
fn passthrough_content_type(source_content_type: &str, key: &str) -> String {
|
|
let extension_mime = mime::extension_mime(key);
|
|
if extension_mime == Some("audio/mp4")
|
|
&& mime::normalize(Some(source_content_type)) == Some("video/mp4")
|
|
{
|
|
return "audio/mp4".to_owned();
|
|
}
|
|
if content_type_is_trustworthy(source_content_type) {
|
|
source_content_type.to_owned()
|
|
} else {
|
|
extension_mime
|
|
.or_else(|| {
|
|
mime::normalize(Some(source_content_type)).filter(|value| {
|
|
!value.is_empty() && !value.eq_ignore_ascii_case("application/octet-stream")
|
|
})
|
|
})
|
|
.unwrap_or("application/octet-stream")
|
|
.to_owned()
|
|
}
|
|
}
|
|
|
|
fn passthrough_disposition_header(
|
|
disposition: &PassthroughDisposition<'_>,
|
|
content_type: &str,
|
|
) -> Option<String> {
|
|
match disposition {
|
|
PassthroughDisposition::None => None,
|
|
PassthroughDisposition::Attachment {
|
|
requested_download,
|
|
filename,
|
|
} => Some(content_disposition_header(
|
|
content_type,
|
|
*requested_download,
|
|
Some(filename),
|
|
)),
|
|
}
|
|
}
|
|
|
|
async fn serve_stored_svg_rasterized(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
data: Bytes,
|
|
cache_identity: &str,
|
|
headers: &HeaderMap,
|
|
disposition: &PassthroughDisposition<'_>,
|
|
) -> Response {
|
|
let format = AssetExtension::Webp;
|
|
let quality = "lossless".to_owned();
|
|
let options = media_process::ImageOptions {
|
|
format,
|
|
quality: quality.clone(),
|
|
animated: false,
|
|
deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64),
|
|
max_encode_frames: Some(app.cfg.max_encode_frames),
|
|
max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms),
|
|
..Default::default()
|
|
};
|
|
let cache_key = transform_cache_key(TransformCacheKeyInput {
|
|
route: TransformRoute::Stored,
|
|
cache_identity,
|
|
width: None,
|
|
height: None,
|
|
format,
|
|
quality: &quality,
|
|
animated: false,
|
|
effort: None,
|
|
});
|
|
if let Some(cached) = app.transform_cache.get(&cache_key) {
|
|
metrics::GLOBAL
|
|
.transform_cache_hits
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
return media_response(
|
|
method,
|
|
cached,
|
|
format.mime(),
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
passthrough_disposition_header(disposition, format.mime()),
|
|
);
|
|
}
|
|
metrics::GLOBAL
|
|
.transform_cache_misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let coalescer_deadline = deadline_instant(options.deadline_ms);
|
|
let transformed = match app
|
|
.coalescer
|
|
.run_once_until(cache_key.clone(), coalescer_deadline, || {
|
|
let app = app.clone();
|
|
let data = data.clone();
|
|
let options = options.clone();
|
|
async move {
|
|
coalesced_work_result(run_transform(&app, data, options).await)
|
|
.map(|media| media.bytes)
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(bytes) => bytes,
|
|
Err(CoalescerError::RequestTimeout) => {
|
|
return text_with_reason(
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
"Gateway Timeout",
|
|
"coalescer_timeout_svg_rasterize",
|
|
);
|
|
}
|
|
Err(CoalescerError::WorkFailed) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"svg_rasterize_failed",
|
|
cache_identity,
|
|
);
|
|
}
|
|
};
|
|
app.transform_cache.put(cache_key, transformed.clone());
|
|
media_response(
|
|
method,
|
|
transformed,
|
|
format.mime(),
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
passthrough_disposition_header(disposition, format.mime()),
|
|
)
|
|
}
|
|
|
|
fn passthrough_head_response(
|
|
content_type: &str,
|
|
total_len: usize,
|
|
byte_range: Option<range::ByteRange>,
|
|
disposition: Option<String>,
|
|
) -> Response {
|
|
let body_len = byte_range.map(|r| r.end - r.start + 1).unwrap_or(total_len);
|
|
let mut response = Response::new(Body::empty());
|
|
*response.status_mut() = if byte_range.is_some() {
|
|
StatusCode::PARTIAL_CONTENT
|
|
} else {
|
|
StatusCode::OK
|
|
};
|
|
http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_len));
|
|
if let Some(disposition) = disposition
|
|
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
|
{
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_DISPOSITION, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
fn streaming_media_response(
|
|
method: Method,
|
|
object: StreamObject,
|
|
total_len: usize,
|
|
content_type: &str,
|
|
disposition: Option<String>,
|
|
) -> Response {
|
|
let status = if object.status == StatusCode::PARTIAL_CONTENT {
|
|
StatusCode::PARTIAL_CONTENT
|
|
} else {
|
|
StatusCode::OK
|
|
};
|
|
let effective_byte_range = if status == StatusCode::PARTIAL_CONTENT {
|
|
object.byte_range
|
|
} else {
|
|
None
|
|
};
|
|
let expected_body_len = effective_byte_range
|
|
.map(|r| r.end - r.start + 1)
|
|
.unwrap_or(total_len);
|
|
let body_len = object
|
|
.content_length
|
|
.and_then(|value| usize::try_from(value).ok())
|
|
.unwrap_or(expected_body_len);
|
|
let response_content_type = if content_type.is_empty() {
|
|
object.content_type.as_str()
|
|
} else {
|
|
content_type
|
|
};
|
|
let mut response = if method == Method::HEAD {
|
|
Response::new(Body::empty())
|
|
} else {
|
|
Response::new(object.body)
|
|
};
|
|
*response.status_mut() = status;
|
|
http_headers::add_media_headers(
|
|
response.headers_mut(),
|
|
total_len,
|
|
response_content_type,
|
|
effective_byte_range,
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_len));
|
|
if let Some(disposition) = disposition
|
|
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
|
{
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_DISPOSITION, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
async fn serve_stored_with_override(
|
|
app: &Arc<AppState>,
|
|
method: Method,
|
|
bucket: &str,
|
|
key: &str,
|
|
content_type: &str,
|
|
headers: &HeaderMap,
|
|
) -> Response {
|
|
let object = match app.store.read_object(bucket, key).await {
|
|
Ok(object) => object,
|
|
Err(err) => return storage_error_response(key, err),
|
|
};
|
|
media_response(
|
|
method,
|
|
object.data,
|
|
content_type,
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
None,
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum TransformRoute {
|
|
Attachment,
|
|
External,
|
|
Stored,
|
|
}
|
|
|
|
struct ServeBytesRequest<'a> {
|
|
method: Method,
|
|
data: Bytes,
|
|
content_type: String,
|
|
cache_identity: &'a str,
|
|
filename: &'a str,
|
|
route: TransformRoute,
|
|
params: &'a HashMap<String, String>,
|
|
headers: &'a HeaderMap,
|
|
}
|
|
|
|
async fn serve_bytes_or_transform(app: &Arc<AppState>, request: ServeBytesRequest<'_>) -> Response {
|
|
let ServeBytesRequest {
|
|
method,
|
|
data,
|
|
content_type,
|
|
cache_identity,
|
|
filename,
|
|
route,
|
|
params,
|
|
headers,
|
|
} = request;
|
|
let animated = animated_param(params, false);
|
|
let sniffed_prefix = mime::sniff(&data[..data.len().min(8192)]);
|
|
let content_type = if sniffed_prefix.mime == "image/svg+xml" {
|
|
"image/svg+xml".to_owned()
|
|
} else if content_type_is_trustworthy(&content_type) {
|
|
content_type
|
|
} else {
|
|
mime::detect(&data[..data.len().min(8192)], filename, Some(&content_type))
|
|
};
|
|
let source_is_svg = is_svg_content_type(&content_type)
|
|
|| image_extension_from_filename(filename) == Some(AssetExtension::Svg);
|
|
let wants_transform = source_is_svg
|
|
|| params.contains_key("width")
|
|
|| params.contains_key("height")
|
|
|| params.contains_key("format")
|
|
|| params.contains_key("quality")
|
|
|| animated;
|
|
let requested_download = bool_param(params, "download", false);
|
|
|
|
if !wants_transform {
|
|
let disposition =
|
|
content_disposition_header(&content_type, requested_download, Some(filename));
|
|
return media_response(
|
|
method,
|
|
data,
|
|
&content_type,
|
|
headers.get(header::RANGE).and_then(|v| v.to_str().ok()),
|
|
Some(disposition),
|
|
);
|
|
}
|
|
|
|
let explicit_requested_format = match explicit_output_format(params) {
|
|
Ok(format) => format,
|
|
Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let width = match parse_optional_dimension_param(params, "width") {
|
|
Ok(width) => width,
|
|
Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let height = match parse_optional_dimension_param(params, "height") {
|
|
Ok(height) => height,
|
|
Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"),
|
|
};
|
|
let media_kind = mime::category(&content_type);
|
|
let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
|
|
|
if media_kind == Some(mime::Category::Video) {
|
|
let Some(requested_format) = explicit_requested_format else {
|
|
if route == TransformRoute::Attachment {
|
|
return text(StatusCode::BAD_REQUEST, "Bad Request");
|
|
}
|
|
let disposition =
|
|
content_disposition_header(&content_type, requested_download, Some(filename));
|
|
return media_response(method, data, &content_type, range_header, Some(disposition));
|
|
};
|
|
let format = output_format::coerce_unsupported_format(requested_format);
|
|
let quality = params
|
|
.get("quality")
|
|
.cloned()
|
|
.unwrap_or_else(|| "high".to_owned());
|
|
let cache_key = transform_cache_key(TransformCacheKeyInput {
|
|
route,
|
|
cache_identity,
|
|
width,
|
|
height,
|
|
format,
|
|
quality: &quality,
|
|
animated,
|
|
effort: None,
|
|
});
|
|
if let Some(cached) = app.transform_cache.get(&cache_key) {
|
|
metrics::GLOBAL
|
|
.transform_cache_hits
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let disposition =
|
|
content_disposition_header(format.mime(), requested_download, Some(filename));
|
|
return media_response(
|
|
method,
|
|
cached,
|
|
format.mime(),
|
|
range_header,
|
|
Some(disposition),
|
|
);
|
|
}
|
|
metrics::GLOBAL
|
|
.transform_cache_misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let deadline_ms = Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64);
|
|
let coalescer_deadline = deadline_instant(deadline_ms);
|
|
let transformed = match app
|
|
.coalescer
|
|
.run_once_until(cache_key.clone(), coalescer_deadline, || {
|
|
let app = app.clone();
|
|
let data = data.clone();
|
|
let quality = quality.clone();
|
|
async move {
|
|
coalesced_work_result(
|
|
run_video_transform(
|
|
&app,
|
|
data,
|
|
format,
|
|
width,
|
|
height,
|
|
quality,
|
|
deadline_ms,
|
|
)
|
|
.await,
|
|
)
|
|
.map(|media| media.bytes)
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(bytes) => bytes,
|
|
Err(CoalescerError::RequestTimeout) => {
|
|
return text_with_reason(
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
"Gateway Timeout",
|
|
"coalescer_timeout_video",
|
|
);
|
|
}
|
|
Err(CoalescerError::WorkFailed) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"video_transform_failed",
|
|
format!(
|
|
"fmt={} w={:?} h={:?} q={}",
|
|
format.name(),
|
|
width,
|
|
height,
|
|
quality
|
|
),
|
|
);
|
|
}
|
|
};
|
|
app.transform_cache.put(cache_key, transformed.clone());
|
|
let disposition =
|
|
content_disposition_header(format.mime(), requested_download, Some(filename));
|
|
return media_response(
|
|
method,
|
|
transformed,
|
|
format.mime(),
|
|
range_header,
|
|
Some(disposition),
|
|
);
|
|
}
|
|
|
|
if media_kind != Some(mime::Category::Image) {
|
|
if route == TransformRoute::Attachment && explicit_requested_format.is_some() {
|
|
return text(StatusCode::BAD_REQUEST, "Bad Request");
|
|
}
|
|
let disposition =
|
|
content_disposition_header(&content_type, requested_download, Some(filename));
|
|
return media_response(method, data, &content_type, range_header, Some(disposition));
|
|
}
|
|
|
|
let sniffed_source = mime::sniff(&data);
|
|
let sniffed_source_format = extension_from_mime(sniffed_source.mime);
|
|
let source_format = if sniffed_source_format == Some(AssetExtension::Apng) {
|
|
Some(AssetExtension::Apng)
|
|
} else {
|
|
extension_from_mime(&content_type).or_else(|| image_extension_from_filename(filename))
|
|
};
|
|
let default_out_ext = match route {
|
|
TransformRoute::Attachment => {
|
|
image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp)
|
|
}
|
|
TransformRoute::External => external_default_output_extension(filename, &content_type),
|
|
TransformRoute::Stored => {
|
|
image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp)
|
|
}
|
|
};
|
|
let requested_format = explicit_requested_format.unwrap_or(default_out_ext);
|
|
let requested_supported_format = output_format::coerce_unsupported_format(requested_format);
|
|
let format =
|
|
effective_animated_image_output_format(source_format, requested_supported_format, animated);
|
|
let response_content_type = transform_response_content_type(
|
|
explicit_requested_format,
|
|
requested_format,
|
|
format,
|
|
&content_type,
|
|
);
|
|
let quality = params.get("quality").cloned().unwrap_or_else(|| {
|
|
default_transform_quality(
|
|
format,
|
|
animated,
|
|
transform_static_quality_default(source_format),
|
|
)
|
|
.to_owned()
|
|
});
|
|
let effort = (route == TransformRoute::Attachment)
|
|
.then(|| parse_effort(params))
|
|
.flatten();
|
|
if same_format_loaded_image_request_can_use_original(
|
|
sniffed_source,
|
|
OriginalImageRequest {
|
|
source_ext: source_format,
|
|
explicit_out_ext: explicit_requested_format,
|
|
out_ext: format,
|
|
width,
|
|
height,
|
|
has_quality: params.contains_key("quality"),
|
|
effort,
|
|
animated,
|
|
},
|
|
) {
|
|
let serve_ct = if content_type.is_empty()
|
|
|| content_type.eq_ignore_ascii_case("application/octet-stream")
|
|
|| extension_from_mime(&content_type).is_none()
|
|
{
|
|
source_format
|
|
.map(|ext| ext.mime().to_owned())
|
|
.unwrap_or_else(|| content_type.clone())
|
|
} else {
|
|
content_type.clone()
|
|
};
|
|
let disposition = content_disposition_header(&serve_ct, requested_download, Some(filename));
|
|
return media_response(method, data, &serve_ct, range_header, Some(disposition));
|
|
}
|
|
let options = media_process::ImageOptions {
|
|
width,
|
|
height,
|
|
format,
|
|
quality: quality.clone(),
|
|
animated,
|
|
effort_override: effort,
|
|
cover_crop: params.contains_key("width") && params.contains_key("height"),
|
|
deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64),
|
|
max_encode_frames: Some(app.cfg.max_encode_frames),
|
|
max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms),
|
|
};
|
|
let cache_key = transform_cache_key(TransformCacheKeyInput {
|
|
route,
|
|
cache_identity,
|
|
width: options.width,
|
|
height: options.height,
|
|
format: options.format,
|
|
quality: &options.quality,
|
|
animated: options.animated,
|
|
effort,
|
|
});
|
|
if let Some(cached) = app.transform_cache.get(&cache_key) {
|
|
metrics::GLOBAL
|
|
.transform_cache_hits
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let disposition =
|
|
content_disposition_header(response_content_type, requested_download, Some(filename));
|
|
return media_response(
|
|
method,
|
|
cached,
|
|
response_content_type,
|
|
range_header,
|
|
Some(disposition),
|
|
);
|
|
}
|
|
metrics::GLOBAL
|
|
.transform_cache_misses
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let coalescer_deadline = deadline_instant(options.deadline_ms);
|
|
let media = match app
|
|
.coalescer
|
|
.run_once_until(cache_key.clone(), coalescer_deadline, || {
|
|
let app = app.clone();
|
|
let data = data.clone();
|
|
let options = options.clone();
|
|
async move {
|
|
coalesced_work_result(run_transform(&app, data, options).await)
|
|
.map(|media| media.bytes)
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(bytes) => {
|
|
app.transform_cache.put(cache_key, bytes.clone());
|
|
ProcessedBytes {
|
|
bytes,
|
|
content_type: response_content_type.to_owned(),
|
|
}
|
|
}
|
|
Err(CoalescerError::RequestTimeout) => {
|
|
return text_with_reason(
|
|
StatusCode::GATEWAY_TIMEOUT,
|
|
"Gateway Timeout",
|
|
"coalescer_timeout_image",
|
|
);
|
|
}
|
|
Err(CoalescerError::WorkFailed) => {
|
|
return text_with_source(
|
|
StatusCode::BAD_REQUEST,
|
|
"Bad Request",
|
|
"image_transform_failed",
|
|
format!(
|
|
"route={:?} cache_identity={} fmt={} w={:?} h={:?} q={} animated={}",
|
|
route,
|
|
cache_identity,
|
|
options.format.name(),
|
|
options.width,
|
|
options.height,
|
|
options.quality,
|
|
options.animated,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
let disposition =
|
|
content_disposition_header(&media.content_type, requested_download, Some(filename));
|
|
media_response(
|
|
method,
|
|
media.bytes,
|
|
&media.content_type,
|
|
range_header,
|
|
Some(disposition),
|
|
)
|
|
}
|
|
|
|
struct ProcessedBytes {
|
|
bytes: Bytes,
|
|
content_type: String,
|
|
}
|
|
|
|
async fn run_transform(
|
|
app: &Arc<AppState>,
|
|
data: Bytes,
|
|
options: media_process::ImageOptions,
|
|
) -> anyhow::Result<media_process::ProcessedMedia> {
|
|
let deadline = deadline_instant(options.deadline_ms);
|
|
let _admission = app.native_transform_admissions.try_wait()?;
|
|
let wait_start = metrics::now_ms();
|
|
let _permit = app.native_transforms.wait_until(deadline).await?;
|
|
let waited = (metrics::now_ms() - wait_start).max(0) as u64;
|
|
metrics::GLOBAL.native_transform_wait.observe(waited);
|
|
let start = metrics::now_ms();
|
|
let result =
|
|
tokio::task::spawn_blocking(move || media_process::transform_image(&data, &options))
|
|
.await??;
|
|
let elapsed = (metrics::now_ms() - start).max(0) as u64;
|
|
metrics::GLOBAL.transform_image_duration.observe(elapsed);
|
|
request_log::record_stage(Stage::Transform, elapsed);
|
|
Ok(result)
|
|
}
|
|
|
|
async fn run_video_transform(
|
|
app: &Arc<AppState>,
|
|
data: Bytes,
|
|
format: AssetExtension,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
quality: String,
|
|
deadline_ms: Option<i64>,
|
|
) -> anyhow::Result<media_process::ProcessedMedia> {
|
|
let deadline = deadline_instant(deadline_ms);
|
|
let _admission = app.native_transform_admissions.try_wait()?;
|
|
let wait_start = metrics::now_ms();
|
|
let _permit = app.native_transforms.wait_until(deadline).await?;
|
|
let waited = (metrics::now_ms() - wait_start).max(0) as u64;
|
|
metrics::GLOBAL.native_transform_wait.observe(waited);
|
|
let start = metrics::now_ms();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
let thumbnail = media_process::extract_video_thumbnail(&data, format)?;
|
|
if width.is_none() && height.is_none() {
|
|
return Ok(thumbnail);
|
|
}
|
|
media_process::transform_image(
|
|
&thumbnail.bytes,
|
|
&media_process::ImageOptions {
|
|
width,
|
|
height,
|
|
format,
|
|
quality,
|
|
animated: false,
|
|
deadline_ms,
|
|
..Default::default()
|
|
},
|
|
)
|
|
})
|
|
.await??;
|
|
let elapsed = (metrics::now_ms() - start).max(0) as u64;
|
|
metrics::GLOBAL.transform_image_duration.observe(elapsed);
|
|
request_log::record_stage(Stage::Transform, elapsed);
|
|
Ok(result)
|
|
}
|
|
|
|
fn coalesced_work_result<T>(result: anyhow::Result<T>) -> anyhow::Result<T> {
|
|
match result {
|
|
Ok(value) => Ok(value),
|
|
Err(error) if transform_error_is_timeout(&error) => {
|
|
Err(anyhow::Error::new(CoalescerError::RequestTimeout))
|
|
}
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
|
|
fn transform_error_is_timeout(error: &anyhow::Error) -> bool {
|
|
error.downcast_ref::<media_process::MediaError>()
|
|
== Some(&media_process::MediaError::RequestTimeout)
|
|
|| error.downcast_ref::<crate::timed_semaphore::TimedSemaphoreError>()
|
|
== Some(&crate::timed_semaphore::TimedSemaphoreError::RequestTimeout)
|
|
}
|
|
|
|
fn transform_admission_capacity(cfg: &Config) -> usize {
|
|
cfg.max_native_transforms + cfg.worker_queue_capacity
|
|
}
|
|
|
|
fn media_response(
|
|
method: Method,
|
|
data: Bytes,
|
|
content_type: &str,
|
|
range_header: Option<&str>,
|
|
disposition: Option<String>,
|
|
) -> Response {
|
|
let total_len = data.len();
|
|
let parsed_range = range::parse_range(range_header, data.len());
|
|
if parsed_range.unsatisfiable {
|
|
let mut response = Response::new(Body::empty());
|
|
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
|
http_headers::add_unsatisfiable_headers(response.headers_mut(), data.len());
|
|
return response;
|
|
}
|
|
let (status, body_bytes, byte_range) = if let Some(r) = parsed_range.range {
|
|
let bytes = data.slice(r.start..=r.end);
|
|
(StatusCode::PARTIAL_CONTENT, bytes, Some(r))
|
|
} else {
|
|
(StatusCode::OK, data, None)
|
|
};
|
|
let mut response = if method == Method::HEAD {
|
|
Response::new(Body::empty())
|
|
} else {
|
|
Response::new(Body::from(body_bytes.clone()))
|
|
};
|
|
*response.status_mut() = status;
|
|
http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(body_bytes.len()));
|
|
if let Some(disposition) = disposition
|
|
&& let Ok(value) = HeaderValue::from_str(&disposition)
|
|
{
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_DISPOSITION, value);
|
|
}
|
|
response
|
|
}
|
|
|
|
fn check_internal_auth(headers: &HeaderMap, secret: &str) -> bool {
|
|
let Some(auth) = headers
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
let expected = format!("Bearer {secret}");
|
|
if auth.len() != expected.len() {
|
|
return false;
|
|
}
|
|
auth.bytes()
|
|
.zip(expected.bytes())
|
|
.fold(0u8, |diff, (a, b)| diff | (a ^ b))
|
|
== 0
|
|
}
|
|
|
|
async fn read_limited_body(request: Request<Body>) -> Result<Bytes, StatusCode> {
|
|
to_bytes(
|
|
request.into_body(),
|
|
constants::MAX_INTERNAL_REQUEST_BODY_BYTES + 1,
|
|
)
|
|
.await
|
|
.map_err(|_| StatusCode::BAD_REQUEST)
|
|
.and_then(|body| {
|
|
if body.len() > constants::MAX_INTERNAL_REQUEST_BODY_BYTES {
|
|
Err(StatusCode::PAYLOAD_TOO_LARGE)
|
|
} else {
|
|
Ok(body)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn storage_status(err: &StorageError) -> StatusCode {
|
|
match err {
|
|
StorageError::NotFound => StatusCode::NOT_FOUND,
|
|
StorageError::ReadOnlyStorage => StatusCode::FORBIDDEN,
|
|
StorageError::InvalidBucket | StorageError::InvalidKey => StatusCode::BAD_REQUEST,
|
|
StorageError::StreamTooLong => StatusCode::PAYLOAD_TOO_LARGE,
|
|
_ => StatusCode::BAD_GATEWAY,
|
|
}
|
|
}
|
|
|
|
fn storage_error_response(key: &str, err: StorageError) -> Response {
|
|
let status = storage_status(&err);
|
|
let body = if status == StatusCode::NOT_FOUND {
|
|
"Not Found"
|
|
} else {
|
|
canonical_reason_str(status)
|
|
};
|
|
text_with_source(
|
|
status,
|
|
body,
|
|
"storage_error",
|
|
format!("key={key} err={err}"),
|
|
)
|
|
}
|
|
|
|
fn relay_error(err: upload_relay::RelayError) -> Response {
|
|
let status = match err {
|
|
upload_relay::RelayError::MissingToken
|
|
| upload_relay::RelayError::InvalidToken
|
|
| upload_relay::RelayError::RelayTokenExpired => StatusCode::UNAUTHORIZED,
|
|
upload_relay::RelayError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
|
|
upload_relay::RelayError::UpstreamRetryable => StatusCode::SERVICE_UNAVAILABLE,
|
|
upload_relay::RelayError::UpstreamS3Error => StatusCode::BAD_GATEWAY,
|
|
_ => StatusCode::BAD_REQUEST,
|
|
};
|
|
let mut response = text(status, status.canonical_reason().unwrap_or("Bad Request"));
|
|
relay_cors(response.headers_mut());
|
|
response
|
|
}
|
|
|
|
fn relay_cors(headers: &mut HeaderMap) {
|
|
headers.insert(
|
|
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
|
HeaderValue::from_static("*"),
|
|
);
|
|
headers.insert(
|
|
header::ACCESS_CONTROL_ALLOW_METHODS,
|
|
HeaderValue::from_static("PUT, OPTIONS"),
|
|
);
|
|
headers.insert(
|
|
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
|
HeaderValue::from_static(
|
|
"Content-Type, Content-Length, Authorization, X-Fluxer-Features, X-Client-Context",
|
|
),
|
|
);
|
|
headers.insert(
|
|
header::ACCESS_CONTROL_EXPOSE_HEADERS,
|
|
HeaderValue::from_static("ETag, X-Fluxer-Version"),
|
|
);
|
|
}
|
|
|
|
fn text(status: StatusCode, body: &str) -> Response {
|
|
text_inner(status, body, None)
|
|
}
|
|
|
|
fn text_with_source(
|
|
status: StatusCode,
|
|
body: &str,
|
|
code: &'static str,
|
|
source: impl std::fmt::Debug,
|
|
) -> Response {
|
|
text_inner(status, body, Some(ErrorReason::with_source(code, source)))
|
|
}
|
|
|
|
fn text_with_reason(status: StatusCode, body: &str, code: &'static str) -> Response {
|
|
text_inner(status, body, Some(ErrorReason::new(code)))
|
|
}
|
|
|
|
const ERROR_CACHE_CONTROL: &str = "no-store";
|
|
|
|
fn text_inner(status: StatusCode, body: &str, reason: Option<ErrorReason>) -> Response {
|
|
let mut response = Response::new(Body::from(body.to_owned()));
|
|
*response.status_mut() = status;
|
|
http_headers::add_security_headers(response.headers_mut());
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
|
);
|
|
response.headers_mut().insert(
|
|
header::X_CONTENT_TYPE_OPTIONS,
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
if status.is_client_error() || status.is_server_error() {
|
|
response.headers_mut().insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static(ERROR_CACHE_CONTROL),
|
|
);
|
|
response
|
|
.extensions_mut()
|
|
.insert(reason.unwrap_or_else(|| ErrorReason::new(canonical_reason_str(status))));
|
|
}
|
|
response
|
|
}
|
|
|
|
fn canonical_reason_str(status: StatusCode) -> &'static str {
|
|
status.canonical_reason().unwrap_or("error")
|
|
}
|
|
|
|
fn json_response(status: StatusCode, body: String) -> Response {
|
|
let mut response = Response::new(Body::from(body));
|
|
*response.status_mut() = status;
|
|
http_headers::add_security_headers(response.headers_mut());
|
|
response.headers_mut().insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_static("application/json"),
|
|
);
|
|
if status.is_client_error() || status.is_server_error() {
|
|
response.headers_mut().insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static(ERROR_CACHE_CONTROL),
|
|
);
|
|
}
|
|
response
|
|
}
|
|
|
|
fn decode_storage_key(path: &str) -> String {
|
|
external_path::percent_decode_string(path.trim_start_matches('/'), false)
|
|
}
|
|
|
|
fn bool_param(params: &HashMap<String, String>, key: &str, default_value: bool) -> bool {
|
|
params
|
|
.get(key)
|
|
.map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1")
|
|
.unwrap_or(default_value)
|
|
}
|
|
|
|
fn animated_param(params: &HashMap<String, String>, default_value: bool) -> bool {
|
|
params
|
|
.get("animated")
|
|
.map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1")
|
|
.unwrap_or(default_value)
|
|
}
|
|
|
|
fn explicit_output_format(params: &HashMap<String, String>) -> Result<Option<AssetExtension>, ()> {
|
|
let Some(raw) = params.get("format") else {
|
|
return Ok(None);
|
|
};
|
|
AssetExtension::parse(raw).map(Some).ok_or(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn parse_dimension(raw: Option<&str>) -> Option<u32> {
|
|
raw.and_then(|v| v.parse::<u32>().ok())
|
|
.filter(|v| *v > 0 && *v <= constants::Limits::image_dimension())
|
|
}
|
|
|
|
fn parse_optional_dimension_param(
|
|
params: &HashMap<String, String>,
|
|
key: &str,
|
|
) -> Result<Option<u32>, ()> {
|
|
let Some(raw) = params.get(key) else {
|
|
return Ok(None);
|
|
};
|
|
if raw.is_empty() {
|
|
return Err(());
|
|
}
|
|
let value = raw.parse::<u32>().map_err(|_| ())?;
|
|
if value == 0 || value > constants::Limits::image_dimension() {
|
|
return Err(());
|
|
}
|
|
Ok(Some(value))
|
|
}
|
|
|
|
fn parse_effort(params: &HashMap<String, String>) -> Option<u8> {
|
|
let raw = params.get("effort")?;
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
raw.parse::<u8>().ok().map(|value| value.min(9))
|
|
}
|
|
|
|
fn deadline_instant(deadline_ms: Option<i64>) -> Option<Instant> {
|
|
let deadline_ms = deadline_ms?;
|
|
let remaining_ms = deadline_ms.saturating_sub(metrics::now_ms()).max(0) as u64;
|
|
Some(Instant::now() + Duration::from_millis(remaining_ms))
|
|
}
|
|
|
|
fn effective_animated_image_output_format(
|
|
source_ext: Option<AssetExtension>,
|
|
requested_out_ext: AssetExtension,
|
|
animated: bool,
|
|
) -> AssetExtension {
|
|
if animated
|
|
&& source_ext == Some(AssetExtension::Apng)
|
|
&& requested_out_ext == AssetExtension::Png
|
|
{
|
|
return AssetExtension::Apng;
|
|
}
|
|
if animated
|
|
&& source_ext == Some(AssetExtension::Gif)
|
|
&& requested_out_ext == AssetExtension::Webp
|
|
{
|
|
return AssetExtension::Gif;
|
|
}
|
|
requested_out_ext
|
|
}
|
|
|
|
fn default_transform_quality(format: AssetExtension, animated: bool, static_default: &str) -> &str {
|
|
if animated && format == AssetExtension::Webp {
|
|
"auto"
|
|
} else {
|
|
static_default
|
|
}
|
|
}
|
|
|
|
fn transform_static_quality_default(source_ext: Option<AssetExtension>) -> &'static str {
|
|
if matches!(
|
|
source_ext,
|
|
Some(AssetExtension::Jpeg | AssetExtension::Heic | AssetExtension::Heif)
|
|
) {
|
|
"high"
|
|
} else {
|
|
"lossless"
|
|
}
|
|
}
|
|
|
|
fn is_v1_asset_manual_format(ext: AssetExtension) -> bool {
|
|
matches!(
|
|
ext,
|
|
AssetExtension::Png
|
|
| AssetExtension::Jpeg
|
|
| AssetExtension::Webp
|
|
| AssetExtension::Gif
|
|
| AssetExtension::Apng
|
|
| AssetExtension::Avif
|
|
)
|
|
}
|
|
|
|
fn asset_manual_format_override(
|
|
params: &HashMap<String, String>,
|
|
url_ext: AssetExtension,
|
|
) -> Option<AssetExtension> {
|
|
let raw = params.get("format").or_else(|| params.get("fmt"));
|
|
if let Some(raw) = raw {
|
|
if raw.eq_ignore_ascii_case("auto") {
|
|
return None;
|
|
}
|
|
if let Some(parsed) = AssetExtension::parse(raw)
|
|
&& is_v1_asset_manual_format(parsed)
|
|
{
|
|
return Some(parsed);
|
|
}
|
|
}
|
|
is_v1_asset_manual_format(url_ext).then_some(url_ext)
|
|
}
|
|
|
|
fn asset_wants_animated(params: &HashMap<String, String>, hash: &str) -> bool {
|
|
animated_param(params, asset_hash::has_animation_prefix(hash))
|
|
}
|
|
|
|
fn animated_image_request_can_use_original(
|
|
source_ext: AssetExtension,
|
|
explicit_out_ext: Option<AssetExtension>,
|
|
out_ext: AssetExtension,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
animated: bool,
|
|
) -> bool {
|
|
if !animated || width.is_some() || height.is_some() {
|
|
return false;
|
|
}
|
|
if !matches!(
|
|
source_ext,
|
|
AssetExtension::Gif | AssetExtension::Webp | AssetExtension::Apng
|
|
) {
|
|
return false;
|
|
}
|
|
let requested = explicit_out_ext.unwrap_or(out_ext);
|
|
requested == source_ext
|
|
}
|
|
|
|
fn same_format_image_request_base_allows_original(
|
|
source_ext: AssetExtension,
|
|
explicit_out_ext: Option<AssetExtension>,
|
|
out_ext: AssetExtension,
|
|
has_quality: bool,
|
|
effort: Option<u8>,
|
|
) -> bool {
|
|
if effort.is_some() {
|
|
return false;
|
|
}
|
|
if !output_format::is_output_format_supported(source_ext) {
|
|
return false;
|
|
}
|
|
if out_ext != source_ext {
|
|
return false;
|
|
}
|
|
let _ = explicit_out_ext;
|
|
if has_quality && source_ext != AssetExtension::Gif {
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct OriginalImageRequest {
|
|
source_ext: Option<AssetExtension>,
|
|
explicit_out_ext: Option<AssetExtension>,
|
|
out_ext: AssetExtension,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
has_quality: bool,
|
|
effort: Option<u8>,
|
|
animated: bool,
|
|
}
|
|
|
|
fn same_format_loaded_image_request_can_use_original(
|
|
sniffed: mime::SniffInfo,
|
|
request: OriginalImageRequest,
|
|
) -> bool {
|
|
let Some(source_ext) = request.source_ext else {
|
|
return false;
|
|
};
|
|
if animated_image_request_can_use_original(
|
|
source_ext,
|
|
request.explicit_out_ext,
|
|
request.out_ext,
|
|
request.width,
|
|
request.height,
|
|
request.animated,
|
|
) {
|
|
return true;
|
|
}
|
|
if !same_format_image_request_base_allows_original(
|
|
source_ext,
|
|
request.explicit_out_ext,
|
|
request.out_ext,
|
|
request.has_quality,
|
|
request.effort,
|
|
) {
|
|
return false;
|
|
}
|
|
if !(request.animated
|
|
|| request.explicit_out_ext.is_some()
|
|
|| request.has_quality
|
|
|| request.width.is_some()
|
|
|| request.height.is_some())
|
|
{
|
|
return false;
|
|
}
|
|
if sniffed.width == 0 || sniffed.height == 0 {
|
|
return false;
|
|
}
|
|
if let Some(target_w) = request.width
|
|
&& target_w < sniffed.width
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(target_h) = request.height
|
|
&& target_h < sniffed.height
|
|
{
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
fn content_type_is_trustworthy(content_type: &str) -> bool {
|
|
if content_type.is_empty() {
|
|
return false;
|
|
}
|
|
if content_type.eq_ignore_ascii_case("application/octet-stream") {
|
|
return false;
|
|
}
|
|
matches!(
|
|
mime::category(content_type),
|
|
Some(mime::Category::Image | mime::Category::Video | mime::Category::Audio)
|
|
)
|
|
}
|
|
|
|
fn is_svg_content_type(content_type: &str) -> bool {
|
|
mime::normalize(Some(content_type))
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("image/svg+xml"))
|
|
}
|
|
|
|
fn extension_from_mime(content_type: &str) -> Option<AssetExtension> {
|
|
match mime::normalize(Some(content_type))? {
|
|
"image/jpeg" => Some(AssetExtension::Jpeg),
|
|
"image/png" => Some(AssetExtension::Png),
|
|
"image/webp" => Some(AssetExtension::Webp),
|
|
"image/gif" => Some(AssetExtension::Gif),
|
|
"image/apng" => Some(AssetExtension::Apng),
|
|
"image/avif" => Some(AssetExtension::Avif),
|
|
"image/heic" => Some(AssetExtension::Heic),
|
|
"image/heif" => Some(AssetExtension::Heif),
|
|
"image/jxl" => Some(AssetExtension::Jxl),
|
|
"image/svg+xml" => Some(AssetExtension::Svg),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn image_extension_from_filename(filename: &str) -> Option<AssetExtension> {
|
|
AssetExtension::parse(extension_of(filename)?)
|
|
}
|
|
|
|
fn external_default_output_extension(filename: &str, content_type: &str) -> AssetExtension {
|
|
extension_from_mime(content_type)
|
|
.or_else(|| image_extension_from_filename(filename))
|
|
.unwrap_or(AssetExtension::Webp)
|
|
}
|
|
|
|
fn transform_response_content_type(
|
|
explicit_out_ext: Option<AssetExtension>,
|
|
requested_out_ext: AssetExtension,
|
|
out_ext: AssetExtension,
|
|
fallback_content_type: &str,
|
|
) -> &str {
|
|
if explicit_out_ext.is_some()
|
|
|| out_ext != requested_out_ext
|
|
|| is_svg_content_type(fallback_content_type)
|
|
{
|
|
out_ext.mime()
|
|
} else {
|
|
fallback_content_type
|
|
}
|
|
}
|
|
|
|
struct TransformCacheKeyInput<'a> {
|
|
route: TransformRoute,
|
|
cache_identity: &'a str,
|
|
width: Option<u32>,
|
|
height: Option<u32>,
|
|
format: AssetExtension,
|
|
quality: &'a str,
|
|
animated: bool,
|
|
effort: Option<u8>,
|
|
}
|
|
|
|
fn asset_transform_cache_key(
|
|
storage_key: &str,
|
|
size: Option<u32>,
|
|
out_ext: AssetExtension,
|
|
quality: &str,
|
|
animated: bool,
|
|
) -> String {
|
|
format!(
|
|
"asset:{storage_key}:{}:{}:{quality}:{animated}",
|
|
size.unwrap_or(0),
|
|
out_ext.name()
|
|
)
|
|
}
|
|
|
|
fn transform_cache_key(input: TransformCacheKeyInput<'_>) -> String {
|
|
let prefix = match input.route {
|
|
TransformRoute::Attachment => "attachment",
|
|
TransformRoute::External => "external",
|
|
TransformRoute::Stored => "stored",
|
|
};
|
|
let identity = match input.route {
|
|
TransformRoute::Attachment | TransformRoute::Stored => input.cache_identity.to_owned(),
|
|
TransformRoute::External => sha256_hex(input.cache_identity.as_bytes()),
|
|
};
|
|
format!(
|
|
"{prefix}:{identity}|w={}|h={}|fmt={}|q={}|anim={}|effort={}",
|
|
input.width.unwrap_or(0),
|
|
input.height.unwrap_or(0),
|
|
input.format.name(),
|
|
input.quality,
|
|
input.animated,
|
|
input.effort.unwrap_or(255),
|
|
)
|
|
}
|
|
|
|
fn sha256_hex(data: &[u8]) -> String {
|
|
hex::encode(Sha256::digest(data))
|
|
}
|
|
|
|
fn last_segment(value: &str) -> &str {
|
|
value
|
|
.rsplit('/')
|
|
.next()
|
|
.filter(|s| !s.is_empty())
|
|
.unwrap_or("file.bin")
|
|
}
|
|
|
|
fn filename_from_storage_key(key: &str) -> &str {
|
|
last_segment(key)
|
|
}
|
|
|
|
fn strip_query_fragment(value: &str) -> &str {
|
|
let query = value.find('?').unwrap_or(value.len());
|
|
let fragment = value.find('#').unwrap_or(value.len());
|
|
&value[..query.min(fragment)]
|
|
}
|
|
|
|
fn url_filename(url: &str) -> String {
|
|
let clean = strip_query_fragment(url);
|
|
let filename = last_segment(clean);
|
|
if filename.is_empty() {
|
|
"external.bin".to_owned()
|
|
} else {
|
|
filename.to_owned()
|
|
}
|
|
}
|
|
|
|
fn extension_of(filename: &str) -> Option<&str> {
|
|
filename.rsplit_once('.').map(|(_, ext)| ext)
|
|
}
|
|
|
|
fn content_disposition_header(
|
|
content_type: &str,
|
|
requested_download: bool,
|
|
filename: Option<&str>,
|
|
) -> String {
|
|
let filename = filename
|
|
.map(|name| download_filename_for_content_type(name, content_type, requested_download));
|
|
disposition::format_header(
|
|
disposition::decide(content_type, requested_download),
|
|
filename.as_deref(),
|
|
)
|
|
}
|
|
|
|
fn download_filename_for_content_type<'a>(
|
|
filename: &'a str,
|
|
content_type: &str,
|
|
requested_download: bool,
|
|
) -> Cow<'a, str> {
|
|
if !requested_download || filename.is_empty() {
|
|
return Cow::Borrowed(filename);
|
|
}
|
|
let Some(expected_ext) = extension_from_mime(content_type) else {
|
|
return Cow::Borrowed(filename);
|
|
};
|
|
if image_extension_from_filename(filename) == Some(expected_ext) {
|
|
return Cow::Borrowed(filename);
|
|
}
|
|
let ext = expected_ext.name();
|
|
let Some((stem, _)) = filename.rsplit_once('.') else {
|
|
return Cow::Owned(format!("{filename}.{ext}"));
|
|
};
|
|
if stem.is_empty() {
|
|
Cow::Owned(format!("{filename}.{ext}"))
|
|
} else {
|
|
Cow::Owned(format!("{stem}.{ext}"))
|
|
}
|
|
}
|
|
|
|
fn nsfw_config(app: &AppState) -> crate::nsfw::Config {
|
|
crate::nsfw::Config {
|
|
endpoint: app.cfg.nsfw_service_endpoint.clone(),
|
|
threshold: app.cfg.nsfw_threshold,
|
|
timeout_ms: 5_000,
|
|
connect_timeout_ms: 1_500,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use base64::engine::general_purpose::STANDARD;
|
|
|
|
fn test_peer(value: &str) -> SocketAddr {
|
|
value.parse().expect("valid socket address")
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_guard_accepts_loopback_peers() {
|
|
assert!(is_loopback_peer(&test_peer("127.0.0.1:5000")));
|
|
assert!(is_loopback_peer(&test_peer("127.0.0.2:5000")));
|
|
assert!(is_loopback_peer(&test_peer("[::1]:5000")));
|
|
assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000")));
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_guard_rejects_remote_peers() {
|
|
assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000")));
|
|
assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000")));
|
|
assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000")));
|
|
assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000")));
|
|
assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000")));
|
|
}
|
|
|
|
fn avatar_cache_key_for_requested_size(raw: &str) -> String {
|
|
let size = constants::parse_image_size(Some(raw));
|
|
let selected = output_format::select_url_variant(output_format::Input {
|
|
kind: AssetKind::Avatar,
|
|
original: AssetExtension::Webp,
|
|
requested_size: Some(size),
|
|
manual_format_override: None,
|
|
});
|
|
asset_transform_cache_key(
|
|
"avatars/852813040100737024/hash",
|
|
selected.size,
|
|
selected.format,
|
|
"high",
|
|
false,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn lossy_source_transform_defaults_to_lossy_output() {
|
|
for lossy in [
|
|
AssetExtension::Jpeg,
|
|
AssetExtension::Heic,
|
|
AssetExtension::Heif,
|
|
] {
|
|
assert_eq!(
|
|
"high",
|
|
default_transform_quality(
|
|
AssetExtension::Webp,
|
|
false,
|
|
transform_static_quality_default(Some(lossy))
|
|
),
|
|
"{} source must not be re-encoded losslessly",
|
|
lossy.name()
|
|
);
|
|
}
|
|
for lossless in [
|
|
AssetExtension::Png,
|
|
AssetExtension::Apng,
|
|
AssetExtension::Gif,
|
|
AssetExtension::Avif,
|
|
AssetExtension::Webp,
|
|
] {
|
|
assert_eq!(
|
|
"lossless",
|
|
default_transform_quality(
|
|
AssetExtension::Webp,
|
|
false,
|
|
transform_static_quality_default(Some(lossless))
|
|
),
|
|
"{} source must keep the lossless default",
|
|
lossless.name()
|
|
);
|
|
}
|
|
assert_eq!(
|
|
"lossless",
|
|
default_transform_quality(
|
|
AssetExtension::Webp,
|
|
false,
|
|
transform_static_quality_default(None)
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"auto",
|
|
default_transform_quality(
|
|
AssetExtension::Webp,
|
|
true,
|
|
transform_static_quality_default(Some(AssetExtension::Jpeg))
|
|
)
|
|
);
|
|
}
|
|
const TRANSFORM_FIXTURE_JPEG_B64: &str = "/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAAbADADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCLyfakMB9Kd/a8B+7Y3B/Kj+1rcplrO5B9lFe77VHgcnmQtD61XeP2q3PqlnHCZPs9ycYBAjB5P44qtPqUC3QgjtpZcjkqORxnp/8AXqfbw7lqjNq5WeP2qB4TV5b2OQgfY5F3EhS5Cg468n60yxheaaR1QGFizHL8/gOKmWJhFlRoSktC0tlcEZa5Ix7mmTw3W1Y4b2NVByxOQW+pweKLm6mjVtj4x04Bq/YSvPp8MkjbmYHJ6ZrzalRuNzWlT5XcoxWZkRXkkEjR/wDPLkdf7vrU7Wz6ltaUynyycK4Ix71fUc4ycD3rL8TzyxaJIsblQ7qjY7g9axdRvQ6Iwv1Kt/qul6aDGG+0SLwUiIIGfU9PyrJHiooT5dhGsZ52lufrWckafZAdozj/ABqrdHBwOwFPmbNY0oLof//Z";
|
|
const TRANSFORM_FIXTURE_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwEAIAAACI8LKTAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqCBUDKiB6/yyRAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA4LTIxVDAzOjQyOjMyKzAwOjAwHW75GgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wOC0yMVQwMzo0MjozMiswMDowMGwzQaYAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDgtMjFUMDM6NDI6MzIrMDA6MDA7JmB5AAABR0lEQVRo3u3boRYBURCA4X/O2RUoyhYKRaYoZF3SPQLPsd5Al3SZopAVCmWLQlmBJ1gTZ8J8T/CfPbNz94aV75fwR/JZQbqwzvBL3i2oP6wz/JLnBpoz6wy/pBhCdrTO8Evue2iPrTP8ktsUOlvrDL/kcoVe1zrDrzjmFXIqYZBaZ/iVfHJIl9YZfskhg1FhneFXUq6gFjuokuz6MDlbZ/gVE6SIY16RlDnU4hSrFBOkkPUL5g3rDL9ighTxgBSxpBUxQYr4UFTEbV4RE6SIHaSIU0wRE6SQeQPWL+sMv+IVU8QrpogJUsQEKWRyhl3fOsOvuGooZFTAIbPO8Ct2kEIGKZxK6wy/4javkF4XLlfrDL+ks4Xb1DrDL2mP4b63zvBLsiMUQ+sMv6Q5g+fGOsMvqT/g3bLO8EvSBZS5dYZfAhC/RFX7AbalYE7qXJRZAAAAAElFTkSuQmCC";
|
|
const TRANSFORM_FIXTURE_MP4_B64: &str = "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMybW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAMgAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlx0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAwAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAADIAAAAAAABAAAAAAHUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAACABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABf21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAT9zdGJsAAAAv3N0c2QAAAAAAAAAAQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAMABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UR7ARAAAAMAEAAAAwFA8SJZYAEABmjr4OSyLP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAACT0AAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAAAgAAAAEAAAAcc3RzegAAAAAAAAAAAAAAAgAAA6YAAAAMAAAAFHN0Y28AAAAAAAAAAQAAA2IAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMgAAAAhmcmVlAAADum1kYXQAAAKuBgX//6rcRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MyBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgzOjB4MTEzIG1lPWhleCBzdWJtZT03IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0xIDh4OGRjdD0xIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MTAgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD00MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTQwLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAADwZYiEAHfOEV8JdDdl6J63yJem6w2D7J9l+nMGcbbyHNpf1RSO+5/egDVWmxn+lFqcntWl2ur6nHe18p73gwfozgPsGKPdA2PIl5OfSx5aTLti3+xINRFg0nFB2J/3DMbcRNRkylQAh0s9Xkm4GU3dOB4ec/yLiY8QloMlsc6H424xJ0LOG/4eoCdscXPOq48R85joaWOcYikCoSmgRa+SbMvql5Q4lBojSvTes7zId5gUKA53scjIpt43LUoIrxlJ4+z2aGeBg4bQRBGyKeyT/PHbUUn/+akrQTU1isl1/vfCvWc29OAaPcKn+qH6pjyjAAAACEGaIWxCX4GA";
|
|
|
|
async fn transform_qualities_reaching_the_encoder(
|
|
fixture_b64: &str,
|
|
content_type: &str,
|
|
filename: &str,
|
|
) -> Vec<String> {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cfg =
|
|
upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901");
|
|
let app = test_app_state(cfg);
|
|
let params = HashMap::from([
|
|
("format".to_owned(), "webp".to_owned()),
|
|
("width".to_owned(), "32".to_owned()),
|
|
]);
|
|
let headers = HeaderMap::new();
|
|
let response = serve_bytes_or_transform(
|
|
&app,
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: Bytes::from(STANDARD.decode(fixture_b64).unwrap()),
|
|
content_type: content_type.to_owned(),
|
|
cache_identity: filename,
|
|
filename,
|
|
route: TransformRoute::Attachment,
|
|
params: ¶ms,
|
|
headers: &headers,
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
StatusCode::OK,
|
|
response.status(),
|
|
"{filename} did not reach the transform path"
|
|
);
|
|
["lossless", "high", "auto", "low"]
|
|
.into_iter()
|
|
.filter(|quality| {
|
|
app.transform_cache
|
|
.get(&transform_cache_key(TransformCacheKeyInput {
|
|
route: TransformRoute::Attachment,
|
|
cache_identity: filename,
|
|
width: Some(32),
|
|
height: None,
|
|
format: AssetExtension::Webp,
|
|
quality,
|
|
animated: false,
|
|
effort: None,
|
|
}))
|
|
.is_some()
|
|
})
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn image_transform_route_sends_lossy_sources_to_a_lossy_encode() {
|
|
assert_eq!(
|
|
vec!["high".to_owned()],
|
|
transform_qualities_reaching_the_encoder(
|
|
TRANSFORM_FIXTURE_JPEG_B64,
|
|
"image/jpeg",
|
|
"photo.jpg"
|
|
)
|
|
.await,
|
|
"a jpeg attachment must not be re-encoded losslessly"
|
|
);
|
|
assert_eq!(
|
|
vec!["lossless".to_owned()],
|
|
transform_qualities_reaching_the_encoder(
|
|
TRANSFORM_FIXTURE_PNG_B64,
|
|
"image/png",
|
|
"art.png"
|
|
)
|
|
.await,
|
|
"a png attachment must keep the lossless encode"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn video_poster_transform_route_sends_decoded_frames_to_a_lossy_encode() {
|
|
assert_eq!(
|
|
vec!["high".to_owned()],
|
|
transform_qualities_reaching_the_encoder(
|
|
TRANSFORM_FIXTURE_MP4_B64,
|
|
"video/mp4",
|
|
"clip.mp4"
|
|
)
|
|
.await,
|
|
"a decoded video frame is already lossy and must not be re-encoded losslessly"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn requested_sizes_off_the_ladder_share_the_cache_key_of_the_rung_they_snap_to() {
|
|
let canonical = avatar_cache_key_for_requested_size("1024");
|
|
assert_eq!(
|
|
"asset:avatars/852813040100737024/hash:1024:webp:high:false",
|
|
canonical
|
|
);
|
|
for raw in ["641", "700", "1000", "1023", "1024"] {
|
|
assert_eq!(
|
|
canonical,
|
|
avatar_cache_key_for_requested_size(raw),
|
|
"size={raw} minted a second cache key"
|
|
);
|
|
}
|
|
let floor = avatar_cache_key_for_requested_size("128");
|
|
assert_eq!(
|
|
"asset:avatars/852813040100737024/hash:128:webp:high:false",
|
|
floor
|
|
);
|
|
for raw in ["1", "17", "20", "100", "128"] {
|
|
assert_eq!(
|
|
floor,
|
|
avatar_cache_key_for_requested_size(raw),
|
|
"size={raw} minted a second cache key"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn requested_sizes_on_different_rungs_keep_distinct_cache_keys() {
|
|
assert_ne!(
|
|
avatar_cache_key_for_requested_size("300"),
|
|
avatar_cache_key_for_requested_size("512")
|
|
);
|
|
assert_ne!(
|
|
avatar_cache_key_for_requested_size("300"),
|
|
avatar_cache_key_for_requested_size("1000")
|
|
);
|
|
}
|
|
|
|
async fn robots_header_for(mode: DeploymentMode) -> Option<String> {
|
|
use axum::{body::Body, http::Request, routing::get};
|
|
let router = Router::new()
|
|
.route(
|
|
"/probe",
|
|
get(|| async {
|
|
let mut response = Response::new(Body::empty());
|
|
http_headers::add_media_headers(response.headers_mut(), 0, "text/plain", None);
|
|
response
|
|
}),
|
|
)
|
|
.layer(middleware::from_fn_with_state(
|
|
mode,
|
|
add_security_header_middleware,
|
|
));
|
|
let response = tower::ServiceExt::oneshot(
|
|
router,
|
|
Request::builder()
|
|
.uri("/probe")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
response
|
|
.headers()
|
|
.get("X-Robots-Tag")
|
|
.map(|v| v.to_str().unwrap().to_owned())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_mode_does_not_set_robots_tag() {
|
|
assert_eq!(robots_header_for(DeploymentMode::Static).await, None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn media_and_upload_modes_still_set_robots_tag() {
|
|
assert_eq!(
|
|
robots_header_for(DeploymentMode::Mp).await.as_deref(),
|
|
Some(http_headers::ROBOTS)
|
|
);
|
|
assert_eq!(
|
|
robots_header_for(DeploymentMode::Upload).await.as_deref(),
|
|
Some(http_headers::ROBOTS)
|
|
);
|
|
}
|
|
|
|
fn upload_relay_test_config(
|
|
storage_root: &std::path::Path,
|
|
spool_dir: &std::path::Path,
|
|
relay_secret: &[u8],
|
|
) -> Config {
|
|
Config::load_from_iter([
|
|
(
|
|
"FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(),
|
|
"secret".to_owned(),
|
|
),
|
|
("FLUXER_MEDIA_PROXY_MODE".to_owned(), "upload".to_owned()),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(),
|
|
"local".to_owned(),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(),
|
|
storage_root.display().to_string(),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64".to_owned(),
|
|
base64::Engine::encode(&STANDARD, relay_secret),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES".to_owned(),
|
|
"4096".to_owned(),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR".to_owned(),
|
|
spool_dir.display().to_string(),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES".to_owned(),
|
|
(1u64 << 30).to_string(),
|
|
),
|
|
])
|
|
.unwrap()
|
|
}
|
|
|
|
fn test_app_state(cfg: Config) -> Arc<AppState> {
|
|
Arc::new(AppState {
|
|
store: Store::new(cfg.clone()),
|
|
client: http_client::build_default(),
|
|
nsfw_client: reqwest::Client::new(),
|
|
transform_cache: Arc::new(Cache::new(
|
|
cfg.transform_cache_capacity_bytes,
|
|
cfg.transform_cache_max_entry_bytes,
|
|
cfg.transform_cache_ttl_ms,
|
|
)),
|
|
external_hints: new_external_hint_cache(&cfg),
|
|
coalescer: Arc::new(ByteCoalescer::new()),
|
|
native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)),
|
|
native_transforms: TimedSemaphore::new(cfg.max_native_transforms),
|
|
cfg,
|
|
})
|
|
}
|
|
|
|
fn static_mode_test_config(storage_root: &std::path::Path) -> Config {
|
|
Config::load_from_iter([
|
|
(
|
|
"FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(),
|
|
"secret".to_owned(),
|
|
),
|
|
("FLUXER_MEDIA_PROXY_MODE".to_owned(), "static".to_owned()),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(),
|
|
"local".to_owned(),
|
|
),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(),
|
|
storage_root.display().to_string(),
|
|
),
|
|
])
|
|
.unwrap()
|
|
}
|
|
|
|
async fn static_mode_cache_control(
|
|
key: &str,
|
|
body: &[u8],
|
|
content_type: &str,
|
|
) -> Option<String> {
|
|
use axum::{body::Body, http::Request};
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cfg = static_mode_test_config(&tmp.path().canonicalize().unwrap());
|
|
let bucket = cfg.bucket_static.clone();
|
|
let state = test_app_state(cfg);
|
|
state
|
|
.store
|
|
.write_object(&bucket, key, body, content_type)
|
|
.await
|
|
.unwrap();
|
|
let router = Router::new()
|
|
.fallback(any(catch_all))
|
|
.with_state(Arc::clone(&state));
|
|
let response = tower::ServiceExt::oneshot(
|
|
router,
|
|
Request::builder()
|
|
.uri(format!("/{key}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(StatusCode::OK, response.status(), "key={key}");
|
|
response
|
|
.headers()
|
|
.get(header::CACHE_CONTROL)
|
|
.map(|value| value.to_str().unwrap().to_owned())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_mode_caches_every_asset_forever() {
|
|
assert_eq!(
|
|
Some("public, max-age=31536000".to_owned()),
|
|
static_mode_cache_control("avatars/0.png", b"\x89PNG\r\n\x1a\n", "image/png").await
|
|
);
|
|
assert_eq!(
|
|
Some("public, max-age=31536000".to_owned()),
|
|
static_mode_cache_control("web/favicon.ico", b"icon", "image/x-icon").await
|
|
);
|
|
assert_eq!(
|
|
Some("public, max-age=31536000".to_owned()),
|
|
static_mode_cache_control("emoji/1f600.svg", b"<svg/>", "image/svg+xml").await
|
|
);
|
|
assert_eq!(
|
|
Some("public, max-age=31536000".to_owned()),
|
|
static_mode_cache_control("web/NOTICE.md", b"notice", "text/markdown").await
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn static_mode_omits_expires_and_relies_on_cache_control() {
|
|
use axum::{body::Body, http::Request};
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cfg = static_mode_test_config(&tmp.path().canonicalize().unwrap());
|
|
let bucket = cfg.bucket_static.clone();
|
|
let state = test_app_state(cfg);
|
|
state
|
|
.store
|
|
.write_object(&bucket, "avatars/0.png", b"png", "image/png")
|
|
.await
|
|
.unwrap();
|
|
let router = Router::new()
|
|
.fallback(any(catch_all))
|
|
.with_state(Arc::clone(&state));
|
|
let response = tower::ServiceExt::oneshot(
|
|
router,
|
|
Request::builder()
|
|
.uri("/avatars/0.png")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert!(response.headers().get(header::EXPIRES).is_none());
|
|
assert_eq!(
|
|
"public, max-age=31536000",
|
|
response.headers().get("CDN-Cache-Control").unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn entrance_sound_path_parses_valid_keys() {
|
|
assert_eq!(
|
|
parse_entrance_sound_path("/entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav"),
|
|
Some("entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav".to_owned())
|
|
);
|
|
for ext in ["mp3", "ogg", "m4a", "wav"] {
|
|
assert_eq!(
|
|
parse_entrance_sound_path(&format!("/entrance-sounds/42/abc123.{ext}")),
|
|
Some(format!("entrance-sounds/42/abc123.{ext}"))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn entrance_sound_path_rejects_invalid_keys() {
|
|
assert_eq!(
|
|
parse_entrance_sound_path("/entrance-sounds/42/abc.flac"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_entrance_sound_path("/entrance-sounds/abc/abc.wav"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_entrance_sound_path("/entrance-sounds/42/abc.wav/x"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_entrance_sound_path("/entrance-sounds/42/../secret.wav"),
|
|
None
|
|
);
|
|
assert_eq!(parse_entrance_sound_path("/entrance-sounds/42"), None);
|
|
assert_eq!(parse_entrance_sound_path("/entrance-sounds//abc.wav"), None);
|
|
assert_eq!(parse_entrance_sound_path("/avatars/42/abc.wav"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_auth_uses_bearer_secret() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_static("Bearer secret"),
|
|
);
|
|
assert!(check_internal_auth(&headers, "secret"));
|
|
assert!(!check_internal_auth(&headers, "other"));
|
|
}
|
|
|
|
#[test]
|
|
fn dimensions_are_bounded() {
|
|
assert_eq!(Some(128), parse_dimension(Some("128")));
|
|
assert_eq!(None, parse_dimension(Some("0")));
|
|
assert_eq!(None, parse_dimension(Some("999999999")));
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_base64_svg_detection_uses_bytes_or_filename() {
|
|
let svg_bytes = InputData {
|
|
data: Bytes::from_static(br#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#),
|
|
filename: "upload.bin".to_owned(),
|
|
};
|
|
assert!(metadata_input_is_svg(&svg_bytes));
|
|
|
|
let svg_filename = InputData {
|
|
data: Bytes::from_static(b"not svg"),
|
|
filename: "icons/logo.svg".to_owned(),
|
|
};
|
|
assert!(metadata_input_is_svg(&svg_filename));
|
|
|
|
let png_filename = InputData {
|
|
data: Bytes::from_static(b"not svg"),
|
|
filename: "icons/logo.png".to_owned(),
|
|
};
|
|
assert!(!metadata_input_is_svg(&png_filename));
|
|
}
|
|
|
|
#[test]
|
|
fn replace_image_extension_only_changes_last_path_segment() {
|
|
assert_eq!(
|
|
"avatars/user.icon.webp",
|
|
replace_image_extension("avatars/user.icon.svg", AssetExtension::Webp)
|
|
);
|
|
assert_eq!(
|
|
"avatars.v1/user.webp",
|
|
replace_image_extension("avatars.v1/user", AssetExtension::Webp)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn metadata_base64_svg_rasterizes_to_webp_bytes() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cfg =
|
|
upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901");
|
|
let app = test_app_state(cfg);
|
|
let input = InputData {
|
|
data: Bytes::from_static(
|
|
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="red"/></svg>"#,
|
|
),
|
|
filename: "icons/logo.svg".to_owned(),
|
|
};
|
|
let raster = match rasterize_metadata_svg(&app, input).await {
|
|
Ok(raster) => raster,
|
|
Err(response) => panic!("unexpected status {}", response.status()),
|
|
};
|
|
|
|
assert_eq!("icons/logo.webp", raster.filename);
|
|
assert_eq!("image/webp", mime::sniff(&raster.data).mime);
|
|
}
|
|
|
|
type PassthroughOriginRequests = Arc<tokio::sync::Mutex<Vec<(Method, Option<String>)>>>;
|
|
|
|
fn parse_origin_range(raw: &str) -> Option<(usize, usize)> {
|
|
let spec = raw.strip_prefix("bytes=")?;
|
|
let (start, end) = spec.split_once('-')?;
|
|
Some((start.parse().ok()?, end.parse().ok()?))
|
|
}
|
|
|
|
async fn passthrough_origin(
|
|
body: &'static [u8],
|
|
answer_416_with_total: bool,
|
|
) -> (String, PassthroughOriginRequests) {
|
|
use axum::body::Body;
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let seen: PassthroughOriginRequests = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
|
let handler = Arc::clone(&seen);
|
|
let router = Router::new().fallback(any(move |request: axum::extract::Request| {
|
|
let seen = Arc::clone(&handler);
|
|
async move {
|
|
let (parts, _body) = request.into_parts();
|
|
let range = parts
|
|
.headers
|
|
.get(header::RANGE)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(ToOwned::to_owned);
|
|
seen.lock().await.push((parts.method, range.clone()));
|
|
let total = body.len();
|
|
let mut response = Response::new(Body::empty());
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png"));
|
|
match range.as_deref().and_then(parse_origin_range) {
|
|
None => {
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(total));
|
|
*response.body_mut() = Body::from(body);
|
|
}
|
|
Some((start, end)) if start < total => {
|
|
let end = end.min(total - 1);
|
|
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
|
|
response.headers_mut().insert(
|
|
header::CONTENT_RANGE,
|
|
HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).unwrap(),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(header::CONTENT_LENGTH, HeaderValue::from(end - start + 1));
|
|
*response.body_mut() = Body::from(&body[start..=end]);
|
|
}
|
|
Some(_) => {
|
|
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
|
if answer_416_with_total {
|
|
response.headers_mut().insert(
|
|
header::CONTENT_RANGE,
|
|
HeaderValue::from_str(&format!("bytes */{total}")).unwrap(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
response
|
|
}
|
|
}));
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, router).await.unwrap();
|
|
});
|
|
(format!("http://{addr}"), seen)
|
|
}
|
|
|
|
fn s3_mode_test_config(endpoint: &str) -> Config {
|
|
Config::load_from_iter([
|
|
(
|
|
"FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(),
|
|
"secret".to_owned(),
|
|
),
|
|
("FLUXER_MEDIA_PROXY_MODE".to_owned(), "mp".to_owned()),
|
|
(
|
|
"FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(),
|
|
"s3".to_owned(),
|
|
),
|
|
("FLUXER_S3_ENDPOINT".to_owned(), endpoint.to_owned()),
|
|
(
|
|
"FLUXER_S3_ACCESS_KEY_ID".to_owned(),
|
|
"AKIAIOSFODNN7EXAMPLE".to_owned(),
|
|
),
|
|
(
|
|
"FLUXER_S3_SECRET_ACCESS_KEY".to_owned(),
|
|
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(),
|
|
),
|
|
])
|
|
.unwrap()
|
|
}
|
|
|
|
async fn passthrough_request(cfg: Config, range: Option<&str>) -> Response {
|
|
use axum::{body::Body, http::Request};
|
|
let state = test_app_state(cfg);
|
|
let router = Router::new()
|
|
.fallback(any(catch_all))
|
|
.with_state(Arc::clone(&state));
|
|
let mut request = Request::builder().uri("/attachments/1/2/pic.png");
|
|
if let Some(range) = range {
|
|
request = request.header(header::RANGE, range);
|
|
}
|
|
tower::ServiceExt::oneshot(router, request.body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_get_serves_the_whole_object_in_one_round_trip() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let response = passthrough_request(s3_mode_test_config(&endpoint), None).await;
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert_eq!(
|
|
"10",
|
|
response.headers().get(header::CONTENT_LENGTH).unwrap()
|
|
);
|
|
assert_eq!(
|
|
"image/png",
|
|
response.headers().get(header::CONTENT_TYPE).unwrap()
|
|
);
|
|
assert!(response.headers().get(header::CONTENT_RANGE).is_none());
|
|
let body = axum::body::to_bytes(response.into_body(), 64)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(b"0123456789", &body[..]);
|
|
assert_eq!(vec![(Method::GET, None)], seen.lock().await.clone());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_ranged_get_reuses_the_upstream_content_range() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let response = passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=2-5")).await;
|
|
|
|
assert_eq!(StatusCode::PARTIAL_CONTENT, response.status());
|
|
assert_eq!(
|
|
"bytes 2-5/10",
|
|
response.headers().get(header::CONTENT_RANGE).unwrap()
|
|
);
|
|
assert_eq!("4", response.headers().get(header::CONTENT_LENGTH).unwrap());
|
|
let body = axum::body::to_bytes(response.into_body(), 64)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(b"2345", &body[..]);
|
|
assert_eq!(
|
|
vec![(Method::GET, Some("bytes=2-5".to_owned()))],
|
|
seen.lock().await.clone()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_unsatisfiable_range_reuses_the_upstream_total() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let response =
|
|
passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=20-30")).await;
|
|
|
|
assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status());
|
|
assert_eq!(
|
|
"bytes */10",
|
|
response.headers().get(header::CONTENT_RANGE).unwrap()
|
|
);
|
|
assert_eq!(
|
|
vec![(Method::GET, Some("bytes=20-30".to_owned()))],
|
|
seen.lock().await.clone()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_falls_back_to_a_head_when_the_upstream_416_omits_the_total() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", false).await;
|
|
let response =
|
|
passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=20-30")).await;
|
|
|
|
assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status());
|
|
assert_eq!(
|
|
"bytes */10",
|
|
response.headers().get(header::CONTENT_RANGE).unwrap()
|
|
);
|
|
assert_eq!(
|
|
vec![
|
|
(Method::GET, Some("bytes=20-30".to_owned())),
|
|
(Method::HEAD, None)
|
|
],
|
|
seen.lock().await.clone()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_ignores_a_malformed_range_instead_of_forwarding_it() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let response =
|
|
passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=abc-def")).await;
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert_eq!(vec![(Method::GET, None)], seen.lock().await.clone());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_answers_a_reversed_range_without_asking_for_the_body() {
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let response =
|
|
passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=10-5")).await;
|
|
|
|
assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status());
|
|
assert_eq!(
|
|
"bytes */10",
|
|
response.headers().get(header::CONTENT_RANGE).unwrap()
|
|
);
|
|
assert_eq!(vec![(Method::HEAD, None)], seen.lock().await.clone());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_get_never_touches_the_write_endpoint() {
|
|
let (origin, origin_seen) = passthrough_origin(b"0123456789", true).await;
|
|
let (read, read_seen) = passthrough_origin(b"0123456789", true).await;
|
|
let mut cfg = s3_mode_test_config(&origin);
|
|
cfg.s3_read_endpoint = Some(read);
|
|
let response = passthrough_request(cfg, None).await;
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert!(origin_seen.lock().await.is_empty());
|
|
assert_eq!(vec![(Method::GET, None)], read_seen.lock().await.clone());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn passthrough_head_stays_a_single_round_trip() {
|
|
use axum::{body::Body, http::Request};
|
|
let (endpoint, seen) = passthrough_origin(b"0123456789", true).await;
|
|
let state = test_app_state(s3_mode_test_config(&endpoint));
|
|
let router = Router::new()
|
|
.fallback(any(catch_all))
|
|
.with_state(Arc::clone(&state));
|
|
let response = tower::ServiceExt::oneshot(
|
|
router,
|
|
Request::builder()
|
|
.method(Method::HEAD)
|
|
.uri("/attachments/1/2/pic.png")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert_eq!(
|
|
"10",
|
|
response.headers().get(header::CONTENT_LENGTH).unwrap()
|
|
);
|
|
assert_eq!(vec![(Method::HEAD, None)], seen.lock().await.clone());
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_content_type_preserves_non_media_metadata() {
|
|
assert_eq!(
|
|
"application/zip",
|
|
passthrough_content_type("application/zip", "downloads/app.zip")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_content_type_prefers_known_extension_over_bad_metadata() {
|
|
assert_eq!(
|
|
"image/png",
|
|
passthrough_content_type("text/plain", "image.png")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn passthrough_content_type_prefers_m4a_extension_over_mp4_metadata() {
|
|
assert_eq!(
|
|
"audio/mp4",
|
|
passthrough_content_type("video/mp4", "track.m4a")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_size_query_is_clamped_by_kind() {
|
|
let params = HashMap::from([("size".to_owned(), "4096".to_owned())]);
|
|
let size = constants::parse_image_size(params.get("size").map(String::as_str));
|
|
let selected = output_format::select_url_variant(output_format::Input {
|
|
kind: AssetKind::Avatar,
|
|
original: AssetExtension::Webp,
|
|
requested_size: Some(size),
|
|
manual_format_override: asset_manual_format_override(¶ms, AssetExtension::Webp),
|
|
});
|
|
assert_eq!(Some(1024), selected.size);
|
|
}
|
|
|
|
#[test]
|
|
fn standard_asset_path_strips_virtual_animation_prefix_and_extension() {
|
|
let parsed =
|
|
parse_standard_asset_path("/avatars/1216100949629702144/a_d2f35261.webp").unwrap();
|
|
assert_eq!("avatars/1216100949629702144/d2f35261", parsed.storage_key);
|
|
assert_eq!(AssetExtension::Webp, parsed.original_ext);
|
|
assert_eq!(AssetKind::Avatar, parsed.kind);
|
|
assert!(asset_wants_animated(&HashMap::new(), &parsed.hash));
|
|
assert!(!asset_wants_animated(
|
|
&HashMap::from([("animated".to_owned(), "false".to_owned())]),
|
|
&parsed.hash
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn guild_member_and_simple_asset_paths_match_v1_storage_keys() {
|
|
let guild =
|
|
parse_guild_member_asset_path("/guilds/1/users/2/avatars/a_memberhash.gif").unwrap();
|
|
assert_eq!("guilds/1/users/2/avatars/memberhash", guild.storage_key);
|
|
assert_eq!(AssetKind::Avatar, guild.kind);
|
|
assert_eq!(AssetExtension::Gif, guild.original_ext);
|
|
assert!(guild.forced_output_format.is_none());
|
|
|
|
let emoji =
|
|
parse_simple_asset_path("/emojis/1501314428688998182.webp", AssetKind::Emoji).unwrap();
|
|
assert_eq!("emojis/1501314428688998182", emoji.storage_key);
|
|
assert_eq!(AssetKind::Emoji, emoji.kind);
|
|
assert!(emoji.forced_output_format.is_none());
|
|
|
|
let sticker =
|
|
parse_simple_asset_path("/stickers/1501314428688998182.png", AssetKind::Sticker)
|
|
.unwrap();
|
|
assert_eq!("stickers/1501314428688998182", sticker.storage_key);
|
|
assert_eq!(Some(AssetExtension::Webp), sticker.forced_output_format);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_manual_format_override_is_v1_compatible() {
|
|
assert_eq!(
|
|
Some(AssetExtension::Webp),
|
|
asset_manual_format_override(&HashMap::new(), AssetExtension::Webp)
|
|
);
|
|
assert_eq!(
|
|
None,
|
|
asset_manual_format_override(
|
|
&HashMap::from([("format".to_owned(), "auto".to_owned())]),
|
|
AssetExtension::Webp
|
|
)
|
|
);
|
|
assert_eq!(
|
|
Some(AssetExtension::Png),
|
|
asset_manual_format_override(
|
|
&HashMap::from([("fmt".to_owned(), "png".to_owned())]),
|
|
AssetExtension::Webp
|
|
)
|
|
);
|
|
assert_eq!(
|
|
Some(AssetExtension::Webp),
|
|
asset_manual_format_override(
|
|
&HashMap::from([("format".to_owned(), "svg".to_owned())]),
|
|
AssetExtension::Webp
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn text_responses_set_nosniff_header() {
|
|
let response = text(StatusCode::NOT_FOUND, "Not Found");
|
|
assert_eq!(
|
|
"nosniff",
|
|
response
|
|
.headers()
|
|
.get(header::X_CONTENT_TYPE_OPTIONS)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
http_headers::STRICT_TRANSPORT_SECURITY,
|
|
response
|
|
.headers()
|
|
.get("strict-transport-security")
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.contains_key(header::CONTENT_SECURITY_POLICY)
|
|
);
|
|
assert!(response.headers().contains_key("permissions-policy"));
|
|
}
|
|
|
|
#[test]
|
|
fn external_should_stream_covers_unknown_length_and_unlabelled_bodies() {
|
|
assert!(external_should_stream(true, "video/mp4"));
|
|
assert!(external_should_stream(true, "application/octet-stream"));
|
|
assert!(external_should_stream(true, ""));
|
|
assert!(!external_should_stream(false, "video/mp4"));
|
|
assert!(!external_should_stream(true, "image/svg+xml"));
|
|
assert!(!external_should_stream(
|
|
true,
|
|
"image/svg+xml; charset=utf-8"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn external_source_is_svg_matches_the_transform_paths_svg_test() {
|
|
let padded_svg = {
|
|
let mut data = b"<!--".to_vec();
|
|
data.resize(6000, b' ');
|
|
data.extend_from_slice(b"--><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>");
|
|
data
|
|
};
|
|
assert!(
|
|
!mime::sniff(&padded_svg[..8192.min(padded_svg.len())])
|
|
.mime
|
|
.eq("image/svg+xml")
|
|
);
|
|
assert!(external_source_is_svg(
|
|
&padded_svg,
|
|
"logo.svg",
|
|
"application/octet-stream"
|
|
));
|
|
assert!(external_source_is_svg(
|
|
b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
|
"logo",
|
|
"application/octet-stream"
|
|
));
|
|
assert!(external_source_is_svg(
|
|
b"\x89PNG\r\n\x1a\n",
|
|
"logo.svg",
|
|
"image/png"
|
|
));
|
|
assert!(!external_source_is_svg(
|
|
b"\x89PNG\r\n\x1a\n",
|
|
"logo.png",
|
|
"image/png"
|
|
));
|
|
assert!(!external_source_is_svg(b"", "clip.mp4", ""));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_streaming_response_passes_body_through() {
|
|
let upstream = reqwest::Response::from(
|
|
http::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body("streamed bytes")
|
|
.unwrap(),
|
|
);
|
|
let response = external_streaming_response(
|
|
Method::GET,
|
|
upstream,
|
|
Bytes::new(),
|
|
Some(14),
|
|
"video/mp4",
|
|
Some("inline; filename=\"clip.mp4\"".to_owned()),
|
|
"https://cdn.example/clip.mp4",
|
|
);
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert_eq!(
|
|
"14",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_LENGTH)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
"video/mp4",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
let body = to_bytes(response.into_body(), 64).await.unwrap();
|
|
assert_eq!(b"streamed bytes", body.as_ref());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_streaming_response_emits_the_sniff_prefix_then_the_rest() {
|
|
let upstream = reqwest::Response::from(
|
|
http::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body("rest of the body")
|
|
.unwrap(),
|
|
);
|
|
let response = external_streaming_response(
|
|
Method::GET,
|
|
upstream,
|
|
Bytes::from_static(b"prefix "),
|
|
None,
|
|
"application/octet-stream",
|
|
None,
|
|
"https://cdn.example/blob.bin",
|
|
);
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
assert!(
|
|
response.headers().get(header::CONTENT_LENGTH).is_none(),
|
|
"an upstream without a Content-Length must stream without inventing one"
|
|
);
|
|
let body = to_bytes(response.into_body(), 64).await.unwrap();
|
|
assert_eq!(b"prefix rest of the body", body.as_ref());
|
|
}
|
|
|
|
const STREAM_TEST_CHUNK_BYTES: usize = 1024 * 1024;
|
|
const STREAM_TEST_URL: &str = "https://cdn.example/clip.mp4";
|
|
|
|
struct TestUpstreamBody {
|
|
chunks: std::collections::VecDeque<Bytes>,
|
|
polls: Arc<std::sync::atomic::AtomicUsize>,
|
|
}
|
|
|
|
impl http_body::Body for TestUpstreamBody {
|
|
type Data = Bytes;
|
|
type Error = std::io::Error;
|
|
|
|
fn poll_frame(
|
|
self: Pin<&mut Self>,
|
|
_cx: &mut Context<'_>,
|
|
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
|
let this = self.get_mut();
|
|
this.polls
|
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
Poll::Ready(this.chunks.pop_front().map(|chunk| Ok(Frame::data(chunk))))
|
|
}
|
|
}
|
|
|
|
fn test_chunks(total: usize) -> std::collections::VecDeque<Bytes> {
|
|
let filler = Bytes::from(vec![b'v'; STREAM_TEST_CHUNK_BYTES]);
|
|
let mut chunks = std::collections::VecDeque::new();
|
|
let mut remaining = total;
|
|
while remaining > 0 {
|
|
let take = remaining.min(STREAM_TEST_CHUNK_BYTES);
|
|
chunks.push_back(filler.slice(..take));
|
|
remaining -= take;
|
|
}
|
|
chunks
|
|
}
|
|
|
|
fn test_upstream(
|
|
total: usize,
|
|
declared: Option<u64>,
|
|
polls: Arc<std::sync::atomic::AtomicUsize>,
|
|
) -> reqwest::Response {
|
|
let mut builder = http::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "video/mp4");
|
|
if let Some(declared) = declared {
|
|
builder = builder.header(header::CONTENT_LENGTH, declared.to_string());
|
|
}
|
|
reqwest::Response::from(
|
|
builder
|
|
.body(reqwest::Body::wrap(TestUpstreamBody {
|
|
chunks: test_chunks(total),
|
|
polls,
|
|
}))
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
async fn drain_counting(body: Body) -> Result<usize, axum::Error> {
|
|
let mut body = body;
|
|
let mut delivered = 0usize;
|
|
while let Some(frame) = body.frame().await {
|
|
let frame = frame?;
|
|
if let Some(data) = frame.data_ref() {
|
|
delivered += data.len();
|
|
}
|
|
}
|
|
Ok(delivered)
|
|
}
|
|
|
|
async fn external_stream_delivered(total: usize) -> Result<usize, axum::Error> {
|
|
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let fetched = external_fetched_from_response(
|
|
test_upstream(total, None, polls),
|
|
STREAM_TEST_URL.to_owned(),
|
|
true,
|
|
)
|
|
.await
|
|
.expect("an upstream without a declared length must be admitted");
|
|
assert_eq!(
|
|
None, fetched.content_length,
|
|
"the fixture must exercise the undeclared length path"
|
|
);
|
|
let ExternalBody::Streaming { response, prefix } = fetched.body else {
|
|
panic!("an undeclared length passthrough must stream");
|
|
};
|
|
let response = external_streaming_response(
|
|
Method::GET,
|
|
response,
|
|
prefix,
|
|
None,
|
|
"video/mp4",
|
|
None,
|
|
&fetched.url,
|
|
);
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
drain_counting(response.into_body()).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_undeclared_length_stream_under_the_cap_is_delivered_whole() {
|
|
let total = constants::MAX_MEDIA_PROXY_BYTES - STREAM_TEST_CHUNK_BYTES;
|
|
assert_eq!(total, external_stream_delivered(total).await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_undeclared_length_stream_exactly_at_the_cap_is_delivered_whole() {
|
|
let total = constants::MAX_MEDIA_PROXY_BYTES;
|
|
assert_eq!(total, external_stream_delivered(total).await.unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_undeclared_length_stream_past_the_cap_fails_the_transfer() {
|
|
let total = constants::MAX_MEDIA_PROXY_BYTES + 1;
|
|
let err = external_stream_delivered(total)
|
|
.await
|
|
.expect_err("a body past the cap must not be delivered as a complete response");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("exceeded the media proxy size cap"),
|
|
"unexpected error: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_declared_length_past_the_cap_is_rejected_before_the_body_is_read() {
|
|
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
let declared = constants::MAX_MEDIA_PROXY_BYTES as u64 + 1;
|
|
let outcome = external_fetched_from_response(
|
|
test_upstream(STREAM_TEST_CHUNK_BYTES, Some(declared), Arc::clone(&polls)),
|
|
STREAM_TEST_URL.to_owned(),
|
|
true,
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(outcome, Err(ExternalFetchError::PayloadTooLarge)),
|
|
"a declared length past the cap must be refused up front"
|
|
);
|
|
assert_eq!(
|
|
0,
|
|
polls.load(std::sync::atomic::Ordering::Relaxed),
|
|
"an oversized declared length must be refused without reading the body"
|
|
);
|
|
}
|
|
|
|
async fn raw_stream_transfer(budget: usize, total: usize) -> Vec<u8> {
|
|
let chunks = test_chunks(total);
|
|
let app = Router::new().route(
|
|
"/probe",
|
|
get(move || {
|
|
let chunks = chunks.clone();
|
|
async move {
|
|
let mut response = Response::new(Body::new(ExternalStreamBody {
|
|
url: STREAM_TEST_URL.to_owned(),
|
|
prefix: None,
|
|
upstream: reqwest::Body::wrap(TestUpstreamBody {
|
|
chunks,
|
|
polls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
|
}),
|
|
remaining: budget,
|
|
}));
|
|
*response.status_mut() = StatusCode::OK;
|
|
response
|
|
}
|
|
}),
|
|
);
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
|
tokio::io::AsyncWriteExt::write_all(
|
|
&mut stream,
|
|
b"GET /probe HTTP/1.1\r\nHost: proxy\r\nConnection: close\r\n\r\n",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut raw = Vec::new();
|
|
tokio::io::AsyncReadExt::read_to_end(&mut stream, &mut raw)
|
|
.await
|
|
.unwrap();
|
|
server.abort();
|
|
raw
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_stream_within_its_budget_completes_the_chunked_transfer() {
|
|
let raw = raw_stream_transfer(STREAM_TEST_CHUNK_BYTES, STREAM_TEST_CHUNK_BYTES).await;
|
|
assert!(raw.starts_with(b"HTTP/1.1 200 OK"));
|
|
assert!(
|
|
raw.ends_with(b"0\r\n\r\n"),
|
|
"a stream inside the cap must terminate the chunked body"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_stream_past_its_budget_never_completes_the_chunked_transfer() {
|
|
let raw = raw_stream_transfer(STREAM_TEST_CHUNK_BYTES, STREAM_TEST_CHUNK_BYTES + 1).await;
|
|
assert!(
|
|
!raw.ends_with(b"0\r\n\r\n"),
|
|
"an overrun must not look like a complete body to the client"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn external_sniffed_content_type_recovers_mislabelled_bodies() {
|
|
assert_eq!(
|
|
"image/svg+xml",
|
|
external_sniffed_content_type(
|
|
b"<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>",
|
|
"logo",
|
|
"application/octet-stream".to_owned()
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"video/mp4",
|
|
external_sniffed_content_type(b"", "clip.mp4", "application/octet-stream".to_owned())
|
|
);
|
|
assert_eq!(
|
|
"video/webm",
|
|
external_sniffed_content_type(b"", "clip.mp4", "video/webm".to_owned())
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_transform_probe_hits_the_entry_the_transform_path_wrote() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cfg =
|
|
upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901");
|
|
let app = test_app_state(cfg);
|
|
let url = "https://cdn.example.test/photo.jpg";
|
|
let params = HashMap::from([("width".to_owned(), "32".to_owned())]);
|
|
let headers = HeaderMap::new();
|
|
let data = Bytes::from(STANDARD.decode(TRANSFORM_FIXTURE_JPEG_B64).unwrap());
|
|
let filename = url_filename(url);
|
|
|
|
assert!(
|
|
external_cached_transform(&app, url, &Method::GET, ¶ms, &headers).is_none(),
|
|
"an unseen url must not resolve to a cached transform"
|
|
);
|
|
|
|
let response = serve_bytes_or_transform(
|
|
&app,
|
|
ServeBytesRequest {
|
|
method: Method::GET,
|
|
data: data.clone(),
|
|
content_type: "image/jpeg".to_owned(),
|
|
cache_identity: url,
|
|
filename: &filename,
|
|
route: TransformRoute::External,
|
|
params: ¶ms,
|
|
headers: &headers,
|
|
},
|
|
)
|
|
.await;
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
let transformed = to_bytes(response.into_body(), 1 << 20).await.unwrap();
|
|
|
|
app.external_hints.insert(
|
|
url.to_owned(),
|
|
external_hint(url, &filename, "image/jpeg", &data),
|
|
);
|
|
let probed = external_cached_transform(&app, url, &Method::GET, ¶ms, &headers)
|
|
.expect("the probe must reach the entry the transform path wrote");
|
|
|
|
assert_eq!(StatusCode::OK, probed.status());
|
|
assert_eq!(
|
|
"image/jpeg",
|
|
probed.headers().get(header::CONTENT_TYPE).unwrap()
|
|
);
|
|
assert_eq!(
|
|
transformed,
|
|
to_bytes(probed.into_body(), 1 << 20).await.unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_partial_response_streams_partial_body() {
|
|
let upstream = reqwest::Response::from(
|
|
http::Response::builder()
|
|
.status(StatusCode::PARTIAL_CONTENT)
|
|
.body("abcd")
|
|
.unwrap(),
|
|
);
|
|
let fetched = FetchedExternal {
|
|
url: "https://media.example.test/clip.webm".to_owned(),
|
|
status: StatusCode::PARTIAL_CONTENT,
|
|
body: ExternalBody::Streaming {
|
|
response: upstream,
|
|
prefix: Bytes::new(),
|
|
},
|
|
content_type: "video/webm".to_owned(),
|
|
content_length: Some(4),
|
|
content_range: Some("bytes 0-3/10".to_owned()),
|
|
};
|
|
let response = external_partial_response(Method::GET, fetched, None);
|
|
|
|
assert_eq!(StatusCode::PARTIAL_CONTENT, response.status());
|
|
assert_eq!(
|
|
"bytes 0-3/10",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_RANGE)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
"4",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_LENGTH)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
let body = to_bytes(response.into_body(), 64).await.unwrap();
|
|
assert_eq!(b"abcd", body.as_ref());
|
|
}
|
|
|
|
fn cache_control_of(response: &Response) -> &str {
|
|
response
|
|
.headers()
|
|
.get(header::CACHE_CONTROL)
|
|
.expect("cache-control is always set")
|
|
.to_str()
|
|
.expect("cache-control is ASCII")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_streaming_response_caches_forever() {
|
|
for (content_type, expected) in [
|
|
("video/mp4", "public, max-age=31536000, no-transform"),
|
|
("image/webp", "public, max-age=31536000"),
|
|
] {
|
|
let upstream = reqwest::Response::from(
|
|
http::Response::builder()
|
|
.status(StatusCode::OK)
|
|
.body("streamed bytes")
|
|
.unwrap(),
|
|
);
|
|
let response = external_streaming_response(
|
|
Method::GET,
|
|
upstream,
|
|
Bytes::new(),
|
|
Some(14),
|
|
content_type,
|
|
None,
|
|
"https://cdn.example/clip.mp4",
|
|
);
|
|
|
|
assert_eq!(
|
|
expected,
|
|
cache_control_of(&response),
|
|
"content_type={content_type}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn external_partial_response_caches_forever() {
|
|
let fetched = FetchedExternal {
|
|
url: "https://media.example.test/clip.webm".to_owned(),
|
|
status: StatusCode::PARTIAL_CONTENT,
|
|
body: ExternalBody::Buffered(Bytes::from_static(b"abcd")),
|
|
content_type: "video/webm".to_owned(),
|
|
content_length: Some(4),
|
|
content_range: Some("bytes 0-3/10".to_owned()),
|
|
};
|
|
let response = external_partial_response(Method::GET, fetched, None);
|
|
|
|
assert_eq!(
|
|
"public, max-age=31536000, no-transform",
|
|
cache_control_of(&response)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_media_responses_cache_forever() {
|
|
let response = media_response(
|
|
Method::GET,
|
|
Bytes::from_static(b"stored bytes"),
|
|
"image/webp",
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!("public, max-age=31536000", cache_control_of(&response));
|
|
|
|
let streamable = media_response(
|
|
Method::GET,
|
|
Bytes::from_static(b"stored bytes"),
|
|
"video/mp4",
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(
|
|
"public, max-age=31536000, no-transform",
|
|
cache_control_of(&streamable)
|
|
);
|
|
|
|
let head = passthrough_head_response("image/webp", 12, None, None);
|
|
assert_eq!("public, max-age=31536000", cache_control_of(&head));
|
|
}
|
|
|
|
#[test]
|
|
fn external_partial_response_uses_media_headers() {
|
|
let fetched = FetchedExternal {
|
|
url: "https://media.example.test/clip.webm".to_owned(),
|
|
status: StatusCode::PARTIAL_CONTENT,
|
|
body: ExternalBody::Buffered(Bytes::from_static(b"abcd")),
|
|
content_type: "video/webm".to_owned(),
|
|
content_length: Some(4),
|
|
content_range: Some("bytes 0-3/10".to_owned()),
|
|
};
|
|
let response = external_partial_response(
|
|
Method::GET,
|
|
fetched,
|
|
Some("inline; filename=\"clip.webm\"".to_owned()),
|
|
);
|
|
|
|
assert_eq!(StatusCode::PARTIAL_CONTENT, response.status());
|
|
assert_eq!(
|
|
"*",
|
|
response
|
|
.headers()
|
|
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
"video/webm",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_TYPE)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
"bytes 0-3/10",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_RANGE)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert_eq!(
|
|
"inline; filename=\"clip.webm\"",
|
|
response
|
|
.headers()
|
|
.get(header::CONTENT_DISPOSITION)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap()
|
|
);
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.contains_key(header::CONTENT_SECURITY_POLICY)
|
|
);
|
|
assert!(response.headers().contains_key("strict-transport-security"));
|
|
assert!(response.headers().contains_key("permissions-policy"));
|
|
assert!(
|
|
response
|
|
.headers()
|
|
.contains_key(header::X_CONTENT_TYPE_OPTIONS)
|
|
);
|
|
assert!(response.headers().contains_key("CDN-Cache-Control"));
|
|
}
|
|
|
|
#[test]
|
|
fn relay_cors_allows_client_context_headers() {
|
|
let mut headers = HeaderMap::new();
|
|
relay_cors(&mut headers);
|
|
let allow_headers = headers
|
|
.get(header::ACCESS_CONTROL_ALLOW_HEADERS)
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap();
|
|
|
|
assert!(
|
|
allow_headers
|
|
.split(',')
|
|
.any(|name| name.trim().eq_ignore_ascii_case("x-fluxer-features"))
|
|
);
|
|
assert!(
|
|
allow_headers
|
|
.split(',')
|
|
.any(|name| name.trim().eq_ignore_ascii_case("x-client-context"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_put_accepts_unknown_content_length_body() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let tmp_root = tmp.path().canonicalize().unwrap();
|
|
let storage_root = tmp_root.join("storage");
|
|
let spool_dir = tmp_root.join("spool");
|
|
tokio::fs::create_dir_all(&spool_dir).await.unwrap();
|
|
let relay_secret = [7u8; 32];
|
|
let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret);
|
|
let key = "guild/diagnostics.txt";
|
|
let token = upload_relay::encode_token(
|
|
&upload_relay::TokenPayload {
|
|
b: "uploads".to_owned(),
|
|
k: key.to_owned(),
|
|
m: upload_relay::TokenMethod::Put,
|
|
u: None,
|
|
p: None,
|
|
ct: Some("text/plain".to_owned()),
|
|
mb: 4096,
|
|
e: upload_relay::now_unix() + 60,
|
|
},
|
|
&relay_secret,
|
|
)
|
|
.unwrap();
|
|
let body = Bytes::from_static(b"diagnostics bundle");
|
|
let request = Request::builder()
|
|
.method(Method::PUT)
|
|
.body(Body::from(body.clone()))
|
|
.unwrap();
|
|
assert!(request.headers().get(header::CONTENT_LENGTH).is_none());
|
|
|
|
let response = relay_put(
|
|
State(test_app_state(cfg)),
|
|
Path(key.to_owned()),
|
|
Query(HashMap::from([("t".to_owned(), token)])),
|
|
HeaderMap::new(),
|
|
request,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
let stored = tokio::fs::read(storage_root.join("uploads").join(key))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body.as_ref(), stored.as_slice());
|
|
}
|
|
|
|
fn relay_test_token(key: &str, relay_secret: &[u8]) -> String {
|
|
upload_relay::encode_token(
|
|
&upload_relay::TokenPayload {
|
|
b: "uploads".to_owned(),
|
|
k: key.to_owned(),
|
|
m: upload_relay::TokenMethod::Put,
|
|
u: None,
|
|
p: None,
|
|
ct: Some("application/octet-stream".to_owned()),
|
|
mb: 4096,
|
|
e: upload_relay::now_unix() + 60,
|
|
},
|
|
relay_secret,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn content_length_headers(declared: u64) -> HeaderMap {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::CONTENT_LENGTH,
|
|
HeaderValue::from_str(&declared.to_string()).unwrap(),
|
|
);
|
|
headers
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_put_streams_known_length_body_without_spooling() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let tmp_root = tmp.path().canonicalize().unwrap();
|
|
let storage_root = tmp_root.join("storage");
|
|
let spool_dir = tmp_root.join("spool");
|
|
tokio::fs::create_dir_all(&spool_dir).await.unwrap();
|
|
let relay_secret = [7u8; 32];
|
|
let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret);
|
|
let key = "guild/streamed.bin";
|
|
let token = relay_test_token(key, &relay_secret);
|
|
let body = Bytes::from_static(b"streamed straight through");
|
|
|
|
let response = relay_put(
|
|
State(test_app_state(cfg)),
|
|
Path(key.to_owned()),
|
|
Query(HashMap::from([("t".to_owned(), token)])),
|
|
content_length_headers(body.len() as u64),
|
|
Request::builder()
|
|
.method(Method::PUT)
|
|
.body(Body::from(body.clone()))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(StatusCode::OK, response.status());
|
|
let stored = tokio::fs::read(storage_root.join("uploads").join(key))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(body.as_ref(), stored.as_slice());
|
|
let mut spool_entries = tokio::fs::read_dir(&spool_dir).await.unwrap();
|
|
assert!(spool_entries.next_entry().await.unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_put_rejects_streaming_body_longer_than_declared() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let tmp_root = tmp.path().canonicalize().unwrap();
|
|
let storage_root = tmp_root.join("storage");
|
|
let spool_dir = tmp_root.join("spool");
|
|
tokio::fs::create_dir_all(&spool_dir).await.unwrap();
|
|
let relay_secret = [7u8; 32];
|
|
let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret);
|
|
let key = "guild/overrun.bin";
|
|
let token = relay_test_token(key, &relay_secret);
|
|
|
|
let response = relay_put(
|
|
State(test_app_state(cfg)),
|
|
Path(key.to_owned()),
|
|
Query(HashMap::from([("t".to_owned(), token)])),
|
|
content_length_headers(4),
|
|
Request::builder()
|
|
.method(Method::PUT)
|
|
.body(Body::from(Bytes::from_static(b"way past four bytes")))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(StatusCode::PAYLOAD_TOO_LARGE, response.status());
|
|
assert!(
|
|
tokio::fs::try_exists(storage_root.join("uploads").join(key))
|
|
.await
|
|
.ok()
|
|
!= Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn error_responses_declare_an_explicit_no_store_policy() {
|
|
for status in [
|
|
StatusCode::NOT_FOUND,
|
|
StatusCode::BAD_GATEWAY,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
] {
|
|
let response = text(status, "nope");
|
|
assert_eq!(
|
|
response.headers().get(header::CACHE_CONTROL).unwrap(),
|
|
"no-store",
|
|
"status {status} must not be cacheable"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn successful_text_responses_are_left_to_the_media_cache_policy() {
|
|
let response = text(StatusCode::OK, "fine");
|
|
assert!(response.headers().get(header::CACHE_CONTROL).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn relay_put_rejects_streaming_body_shorter_than_declared() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let tmp_root = tmp.path().canonicalize().unwrap();
|
|
let storage_root = tmp_root.join("storage");
|
|
let spool_dir = tmp_root.join("spool");
|
|
tokio::fs::create_dir_all(&spool_dir).await.unwrap();
|
|
let relay_secret = [7u8; 32];
|
|
let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret);
|
|
let key = "guild/short.bin";
|
|
let token = relay_test_token(key, &relay_secret);
|
|
|
|
let response = relay_put(
|
|
State(test_app_state(cfg)),
|
|
Path(key.to_owned()),
|
|
Query(HashMap::from([("t".to_owned(), token)])),
|
|
content_length_headers(32),
|
|
Request::builder()
|
|
.method(Method::PUT)
|
|
.body(Body::from(Bytes::from_static(b"tiny")))
|
|
.unwrap(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(StatusCode::BAD_REQUEST, response.status());
|
|
assert!(
|
|
tokio::fs::try_exists(storage_root.join("uploads").join(key))
|
|
.await
|
|
.ok()
|
|
!= Some(true)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upstream_status_is_clamped_to_known_whitelist() {
|
|
assert_eq!(
|
|
StatusCode::NOT_FOUND,
|
|
map_upstream_status(StatusCode::NOT_FOUND)
|
|
);
|
|
assert_eq!(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
map_upstream_status(StatusCode::TOO_MANY_REQUESTS)
|
|
);
|
|
assert_eq!(
|
|
StatusCode::from_u16(428).unwrap(),
|
|
map_upstream_status(StatusCode::from_u16(428).unwrap())
|
|
);
|
|
assert_eq!(
|
|
StatusCode::BAD_GATEWAY,
|
|
map_upstream_status(StatusCode::from_u16(451).unwrap())
|
|
);
|
|
assert_eq!(
|
|
StatusCode::BAD_GATEWAY,
|
|
map_upstream_status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
);
|
|
assert_eq!(
|
|
StatusCode::BAD_GATEWAY,
|
|
map_upstream_status(StatusCode::SERVICE_UNAVAILABLE)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn internal_metadata_does_not_surface_origin_429() {
|
|
assert_eq!(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
map_internal_metadata_upstream_status(StatusCode::TOO_MANY_REQUESTS)
|
|
);
|
|
assert_eq!(
|
|
StatusCode::NOT_FOUND,
|
|
map_internal_metadata_upstream_status(StatusCode::NOT_FOUND)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_filename_rejects_unstable_shapes() {
|
|
assert!(parse_asset_filename("hash.extra.png").is_none());
|
|
assert!(parse_asset_filename("hash-with-dash.png").is_none());
|
|
assert!(parse_asset_filename(".png").is_none());
|
|
assert!(parse_asset_filename("hash.").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn animated_gif_requests_downgrade_webp_output_to_gif() {
|
|
assert_eq!(
|
|
AssetExtension::Apng,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Apng),
|
|
AssetExtension::Png,
|
|
true
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Png,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Apng),
|
|
AssetExtension::Png,
|
|
false
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Gif,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Gif),
|
|
AssetExtension::Webp,
|
|
true
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Webp,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Gif),
|
|
AssetExtension::Webp,
|
|
false
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Webp,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Webp),
|
|
AssetExtension::Webp,
|
|
true
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Png,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Png),
|
|
AssetExtension::Png,
|
|
true
|
|
)
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Png,
|
|
effective_animated_image_output_format(
|
|
Some(AssetExtension::Png),
|
|
AssetExtension::Png,
|
|
false
|
|
)
|
|
);
|
|
assert!(animated_param(
|
|
&HashMap::from([("animated".to_owned(), "true".to_owned())]),
|
|
false
|
|
));
|
|
assert!(!animated_param(
|
|
&HashMap::from([("animatd".to_owned(), "true".to_owned())]),
|
|
false
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn attachment_and_external_query_helpers_match_v1_edges() {
|
|
for name in [
|
|
"clip.mp4",
|
|
"clip.m4v",
|
|
"clip.webm",
|
|
"clip.mov",
|
|
"clip.ogv",
|
|
"clip.mkv",
|
|
"clip.3gp",
|
|
"clip.avi",
|
|
"clip.flv",
|
|
"clip.ts",
|
|
"clip.mpg",
|
|
"clip.mpeg",
|
|
"clip.wmv",
|
|
] {
|
|
assert_eq!(None, image_extension_from_filename(name));
|
|
}
|
|
assert_eq!(
|
|
AssetExtension::Gif,
|
|
external_default_output_extension("welcome.png", "image/gif")
|
|
);
|
|
assert_eq!(
|
|
AssetExtension::Png,
|
|
external_default_output_extension("welcome.png", "application/octet-stream")
|
|
);
|
|
assert_eq!(
|
|
"image/gif",
|
|
transform_response_content_type(
|
|
None,
|
|
AssetExtension::Gif,
|
|
AssetExtension::Gif,
|
|
"image/gif"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"image/webp",
|
|
transform_response_content_type(
|
|
None,
|
|
AssetExtension::Heic,
|
|
AssetExtension::Webp,
|
|
"image/heic"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"image/webp",
|
|
transform_response_content_type(
|
|
None,
|
|
AssetExtension::Webp,
|
|
AssetExtension::Webp,
|
|
"image/svg+xml; charset=utf-8"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"image/gif",
|
|
transform_response_content_type(
|
|
Some(AssetExtension::Webp),
|
|
AssetExtension::Webp,
|
|
AssetExtension::Gif,
|
|
"image/gif"
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"file.png",
|
|
url_filename("https://example.test/a/file.png?x=1#frag")
|
|
);
|
|
assert_eq!(
|
|
"photo.png",
|
|
filename_from_storage_key("attachments/123/456/photo.png")
|
|
);
|
|
assert_eq!(
|
|
"file.bin",
|
|
filename_from_storage_key("attachments/123/456/")
|
|
);
|
|
assert!(
|
|
explicit_output_format(&HashMap::from([("format".to_owned(), "auto".to_owned())]))
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_download_disposition_uses_response_image_extension() {
|
|
assert_eq!(
|
|
"attachment; filename=\"welcome.gif\"",
|
|
content_disposition_header("image/gif", true, Some("welcome.png"))
|
|
);
|
|
assert_eq!(
|
|
"attachment; filename=\"welcome.gif\"",
|
|
content_disposition_header("image/gif", true, Some("welcome"))
|
|
);
|
|
assert_eq!(
|
|
"attachment; filename=\"photo.jpg\"",
|
|
content_disposition_header("image/jpeg", true, Some("photo.jpg"))
|
|
);
|
|
assert_eq!(
|
|
"attachment; filename=\"photo.jpg\"",
|
|
content_disposition_header(
|
|
"image/jpeg",
|
|
true,
|
|
Some(filename_from_storage_key("attachments/123/456/photo.jpg"))
|
|
)
|
|
);
|
|
assert_eq!(
|
|
"inline; filename=\"welcome.png\"",
|
|
content_disposition_header("image/gif", false, Some("welcome.png"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn same_format_gif_noop_requests_use_original_bytes() {
|
|
let gif_header = mime::sniff(b"GIF89a\x2c\x01\xe1\x00");
|
|
assert!(same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: None,
|
|
out_ext: AssetExtension::Gif,
|
|
width: Some(300),
|
|
height: Some(225),
|
|
has_quality: false,
|
|
effort: None,
|
|
animated: true,
|
|
}
|
|
));
|
|
assert!(same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: None,
|
|
out_ext: AssetExtension::Gif,
|
|
width: Some(301),
|
|
height: None,
|
|
has_quality: false,
|
|
effort: None,
|
|
animated: true,
|
|
}
|
|
));
|
|
assert!(!same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: None,
|
|
out_ext: AssetExtension::Gif,
|
|
width: Some(299),
|
|
height: None,
|
|
has_quality: false,
|
|
effort: None,
|
|
animated: true,
|
|
}
|
|
));
|
|
assert!(!same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: Some(AssetExtension::Webp),
|
|
out_ext: AssetExtension::Webp,
|
|
width: None,
|
|
height: None,
|
|
has_quality: false,
|
|
effort: None,
|
|
animated: true,
|
|
}
|
|
));
|
|
assert!(!same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: Some(AssetExtension::Webp),
|
|
out_ext: AssetExtension::Webp,
|
|
width: Some(300),
|
|
height: None,
|
|
has_quality: false,
|
|
effort: None,
|
|
animated: false,
|
|
}
|
|
));
|
|
assert!(!same_format_loaded_image_request_can_use_original(
|
|
gif_header,
|
|
OriginalImageRequest {
|
|
source_ext: Some(AssetExtension::Gif),
|
|
explicit_out_ext: None,
|
|
out_ext: AssetExtension::Gif,
|
|
width: Some(300),
|
|
height: None,
|
|
has_quality: false,
|
|
effort: Some(1),
|
|
animated: false,
|
|
}
|
|
));
|
|
}
|
|
}
|