tuwunel_service/media/thumbnail/animate.rs
1//! Defines animated thumbnail admission and selection policy.
2
3use tuwunel_core::implement;
4
5use super::{
6 super::Fetched,
7 sniff::{animated_type, animates},
8};
9
10/// Content types naming a container that can carry a frame sequence.
11///
12/// A picture read as animating is stored under the one its header names, so a
13/// later lookup holding the key rather than the picture still knows what the
14/// row carries.
15pub(super) const APNG: &str = "image/apng";
16pub(super) const GIF: &str = "image/gif";
17pub(super) const WEBP: &str = "image/webp";
18
19/// Content types withheld from a request that asked for a still picture.
20///
21/// A still `image/webp` cannot be told from an animated one without decoding
22/// it, so the family is withheld whole. MSC2705 also names `image/png` for
23/// APNG, which cannot join the list because every generated thumbnail is one.
24pub(in super::super) const ANIMATED_TYPES: [&str; 3] = [APNG, GIF, WEBP];
25
26/// Whether a thumbnail request will accept an animated result.
27///
28/// MSC2705 gives the `animated` parameter three states and two behaviors: only
29/// `animated=true` may be answered with animation, while `animated=false` and
30/// an absent parameter alike forbid it.
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub enum Animate {
33 /// The response must be a still picture.
34 #[default]
35 Never,
36
37 /// The response may animate.
38 Allowed,
39}
40/// Returns true when the request will accept animation.
41///
42/// Only `animated=true` reaches this state; `animated=false` and an absent
43/// parameter alike forbid animation.
44#[implement(Animate)]
45#[inline]
46#[must_use]
47pub fn allowed(self) -> bool { matches!(self, Self::Allowed) }
48
49/// Returns true when content of this type may answer the request at all.
50///
51/// This reads the declared type, which whoever uploaded the picture chose,
52/// so it is only for deciding between stored rows, where the pictures
53/// themselves are not in hand. Prefer [`Self::accepts_picture`] anywhere
54/// the bytes are.
55#[implement(Animate)]
56#[inline]
57#[must_use]
58pub fn accepts_type(self, content_type: Option<&str>) -> bool {
59 self.allowed() || !content_type.is_some_and(declares_animation)
60}
61
62/// Returns true when this type is the variant the request asked for.
63///
64/// A source that animates leaves both variants stored at a size, so which
65/// one a lookup answers with must be stated rather than left to the order
66/// their keys fall in, which a remote row's own disposition decides. The
67/// other variant still answers when it is the only one there, so this
68/// orders the rows rather than refusing any of them.
69#[implement(Animate)]
70#[inline]
71#[must_use]
72pub fn prefers_type(self, content_type: Option<&str>) -> bool {
73 self.allowed() == content_type.is_some_and(declares_animation)
74}
75
76/// Returns true when this picture may answer the request.
77///
78/// The bytes decide, so a file cannot pass a request that forbade
79/// animation by declaring a content type that does not animate, and an
80/// APNG is caught despite being an `image/png` like every thumbnail.
81#[implement(Animate)]
82#[inline]
83#[must_use]
84pub fn accepts_picture(self, bytes: &[u8]) -> bool { self.allowed() || !animates(bytes) }
85
86/// Returns true when a fetched picture may answer, walking it if nobody
87/// has.
88///
89/// Filing a row settles this on the way, and a redirect files none, so the
90/// walk that was skipped there happens here for the one caller that asks.
91#[implement(Animate)]
92#[must_use]
93pub fn accepts_fetched(self, fetched: &Fetched) -> bool {
94 self.accepts_walk(fetched.animates, &fetched.media.content)
95}
96
97/// Returns true when this picture may answer in a thumbnail's place.
98///
99/// Nothing can be derived from a picture that will not decode, so the
100/// choice is between the original and refusing media the server holds. Only
101/// a walk that settled on animation withholds it here, where
102/// [`Self::accepts_picture`] withholds anything it could not settle, since
103/// refusing every unreadable still would cost more than it buys.
104#[implement(Animate)]
105#[inline]
106#[must_use]
107pub fn accepts_fallback(self, bytes: &[u8]) -> bool {
108 self.allowed() || animated_type(bytes).is_none()
109}
110
111/// Returns true when this picture may answer, walking it if nobody has.
112///
113/// A caller already holding a walk of these bytes states what it settled
114/// rather than paying for a second one over them, and where none was taken
115/// this is [`Self::accepts_picture`] exactly.
116#[implement(Animate)]
117#[inline]
118#[must_use]
119pub(in super::super) fn accepts_walk(self, animates: Option<bool>, bytes: &[u8]) -> bool {
120 animates
121 .map_or_else(|| self.accepts_picture(bytes), |animates| self.accepts_animation(animates))
122}
123
124/// Returns true when this picture may answer in a thumbnail's place,
125/// walking it if nobody has.
126///
127/// The settled-only rule of [`Self::accepts_fallback`] holds here too, so
128/// what the caller states is whether its walk *named* an animation rather
129/// than whether it withheld one.
130#[implement(Animate)]
131#[cfg(feature = "media_thumbnail")]
132#[inline]
133#[must_use]
134pub(in super::super) fn accepts_fallback_walk(self, names: Option<bool>, bytes: &[u8]) -> bool {
135 names.map_or_else(|| self.accepts_fallback(bytes), |names| self.allowed() || !names)
136}
137
138/// Returns true when a picture may answer, given what a walk settled about
139/// it.
140///
141/// The walk that settles this is the same one picking the type a fetched
142/// row is filed under, so a caller already holding its answer states it
143/// here rather than reading the same bytes again through
144/// [`Self::accepts_picture`].
145#[implement(Animate)]
146#[inline]
147#[must_use]
148fn accepts_animation(self, animates: bool) -> bool { self.allowed() || !animates }
149
150impl From<Option<bool>> for Animate {
151 fn from(animated: Option<bool>) -> Self {
152 match animated {
153 | Some(true) => Self::Allowed,
154 | Some(false) | None => Self::Never,
155 }
156 }
157}
158
159impl From<Animate> for Option<bool> {
160 fn from(animate: Animate) -> Self { Some(animate.allowed()) }
161}
162
163/// Whether a declared content type names an animating container.
164///
165/// This reads what an upload claimed rather than the picture itself, so it
166/// decides only between stored rows, where no picture is in hand. The reader
167/// that decides from the bytes is `sniff::animates`.
168fn declares_animation(content_type: &str) -> bool {
169 let essence = content_type
170 .split_once(';')
171 .map_or(content_type, |(essence, _)| essence)
172 .trim();
173
174 ANIMATED_TYPES
175 .iter()
176 .any(|kind| essence.eq_ignore_ascii_case(kind))
177}