Files
fluxer/fluxer_media_proxy/src/constants.rs
T

231 lines
7.2 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
pub const MAX_MEDIA_PROXY_BYTES: usize = 500 * 1024 * 1024;
pub const DESKTOP_APP_ORIGIN: &str = "fluxer-app://app";
pub const OUTBOUND_USER_AGENT: &str =
"Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)";
pub const MAX_MEDIA_IMAGE_DIMENSION_DEFAULT: u32 = 16_384;
pub const MAX_MEDIA_IMAGE_PIXELS_DEFAULT: usize =
MAX_MEDIA_IMAGE_DIMENSION_DEFAULT as usize * MAX_MEDIA_IMAGE_DIMENSION_DEFAULT as usize;
pub const MAX_INTERNAL_REQUEST_BODY_BYTES: usize =
MAX_MEDIA_PROXY_BYTES.div_ceil(3) * 4 + 1024 * 1024;
pub const MAX_VIDEO_PACKETS_FOR_THUMBNAIL: usize = 512;
pub const DEFAULT_IMAGE_SIZE: u32 = 128;
pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000;
pub const MAX_ANIMATED_TOTAL_PIXELS_DEFAULT: usize = 4 * MAX_MEDIA_IMAGE_PIXELS_DEFAULT;
pub const MAX_JXL_DECODE_PIXELS: usize = 4096 * 4096;
pub const MAX_STATIC_OUTPUT_PIXELS: usize = 6144 * 4096;
pub const MAX_ANIMATED_OUTPUT_PIXELS: usize = 16 << 20;
pub const SVG_RASTER_MAX_DIMENSION: u32 = 4096;
pub const LARGE_SOURCE_LOG_PIXELS: usize = 4096 * 4096;
const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000);
pub const IMAGE_SIZES: &[u32] = &[
16, 20, 22, 24, 28, 32, 40, 44, 48, 56, 60, 64, 80, 96, 100, 128, 160, 240, 256, 300, 320, 480,
512, 600, 640, 1024, 1280, 1536, 2048, 3072, 4096, 8192, 16384,
];
pub fn snap_to_image_ladder(value: u32) -> u32 {
let largest = IMAGE_SIZES[IMAGE_SIZES.len() - 1];
IMAGE_SIZES
.iter()
.copied()
.find(|rung| value <= *rung)
.unwrap_or(largest)
}
pub fn parse_image_size(raw: Option<&str>) -> u32 {
let Some(text) = raw else {
return DEFAULT_IMAGE_SIZE;
};
let Ok(value) = text.parse::<u32>() else {
return DEFAULT_IMAGE_SIZE;
};
snap_to_image_ladder(value)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum AssetKind {
Avatar,
GuildIcon,
Banner,
Splash,
EmbedSplash,
Emoji,
Sticker,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum AssetExtension {
Png,
Jpeg,
Webp,
Gif,
Apng,
Avif,
Heic,
Heif,
Jxl,
Svg,
}
impl AssetExtension {
pub fn parse(raw: &str) -> Option<Self> {
match raw.to_ascii_lowercase().as_str() {
"png" => Some(Self::Png),
"jpg" | "jpeg" => Some(Self::Jpeg),
"webp" => Some(Self::Webp),
"gif" => Some(Self::Gif),
"apng" => Some(Self::Apng),
"avif" => Some(Self::Avif),
"heic" => Some(Self::Heic),
"heif" => Some(Self::Heif),
"jxl" => Some(Self::Jxl),
"svg" => Some(Self::Svg),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
Self::Webp => "webp",
Self::Gif => "gif",
Self::Apng => "apng",
Self::Avif => "avif",
Self::Heic => "heic",
Self::Heif => "heif",
Self::Jxl => "jxl",
Self::Svg => "svg",
}
}
pub fn mime(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Gif => "image/gif",
Self::Apng => "image/apng",
Self::Avif => "image/avif",
Self::Heic => "image/heic",
Self::Heif => "image/heif",
Self::Jxl => "image/jxl",
Self::Svg => "image/svg+xml",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Dims {
pub min: u32,
pub max: u32,
}
pub fn dims_for(kind: AssetKind) -> Dims {
match kind {
AssetKind::Avatar | AssetKind::GuildIcon => Dims {
min: 128,
max: 1024,
},
AssetKind::Banner | AssetKind::Splash | AssetKind::EmbedSplash => Dims {
min: 480,
max: 2400,
},
AssetKind::Emoji => Dims { min: 32, max: 512 },
AssetKind::Sticker => Dims { min: 128, max: 512 },
}
}
pub fn clamp_size(raw_target: u32, kind: AssetKind) -> u32 {
let value = raw_target.max(1);
let dims = dims_for(kind);
value.clamp(dims.min, dims.max)
}
#[cfg(test)]
mod tests {
use super::*;
fn asset_cache_key(raw: Option<&str>, kind: AssetKind) -> u32 {
clamp_size(parse_image_size(raw), kind)
}
#[test]
fn image_size_snaps_up_to_the_next_rung() {
assert_eq!(128, parse_image_size(None));
assert_eq!(640, parse_image_size(Some("640")));
assert_eq!(1024, parse_image_size(Some("641")));
assert_eq!(1024, parse_image_size(Some("1000")));
assert_eq!(16, parse_image_size(Some("0")));
assert_eq!(16384, parse_image_size(Some("99999")));
assert_eq!(128, parse_image_size(Some("not-a-number")));
assert_eq!(128, parse_image_size(Some("")));
assert_eq!(128, parse_image_size(Some("-1")));
assert_eq!(128, parse_image_size(Some("99999999999")));
}
#[test]
fn off_ladder_size_never_serves_fewer_pixels_than_requested() {
for requested in 1..=4096u32 {
let served = parse_image_size(Some(&requested.to_string()));
assert!(
served >= requested,
"size={requested} served {served}, a silent downscale"
);
}
}
#[test]
fn every_rung_snaps_to_itself() {
for rung in IMAGE_SIZES {
assert_eq!(*rung, parse_image_size(Some(&rung.to_string())));
}
}
#[test]
fn snapping_is_idempotent() {
for raw in [0u32, 1, 17, 641, 1000, 4097, 99999] {
let once = parse_image_size(Some(&raw.to_string()));
let twice = parse_image_size(Some(&once.to_string()));
assert_eq!(once, twice, "size={raw} did not settle");
}
}
#[test]
fn sub_minimum_sizes_collapse_to_one_avatar_cache_key() {
let canonical = asset_cache_key(Some("128"), AssetKind::Avatar);
assert_eq!(128, canonical);
for below in IMAGE_SIZES.iter().take_while(|rung| **rung < 128) {
assert_eq!(
canonical,
asset_cache_key(Some(&below.to_string()), AssetKind::Avatar),
"size={below} minted a second avatar cache key"
);
}
}
#[test]
fn sub_minimum_sizes_collapse_to_one_banner_cache_key() {
let canonical = asset_cache_key(Some("480"), AssetKind::Banner);
assert_eq!(480, canonical);
for below in IMAGE_SIZES.iter().take_while(|rung| **rung < 480) {
assert_eq!(
canonical,
asset_cache_key(Some(&below.to_string()), AssetKind::Banner),
"size={below} minted a second banner cache key"
);
}
}
#[test]
fn oversize_requests_collapse_onto_the_kind_maximum() {
assert_eq!(1024, asset_cache_key(Some("99999"), AssetKind::Avatar));
assert_eq!(1024, asset_cache_key(Some("1024"), AssetKind::Avatar));
assert_eq!(512, asset_cache_key(Some("99999"), AssetKind::Emoji));
assert_eq!(512, asset_cache_key(Some("99999"), AssetKind::Sticker));
}
}