Skip to main content

chan/
post.rs

1use serde::{Deserialize, Serialize};
2
3use crate::raw::RawPost;
4
5/// A sanitized post. Attachment metadata is split out into [`Attachment`].
6///
7/// 4chan's raw post JSON mixes post metadata and attachment metadata into the
8/// same object, with most attachment fields optional. We normalize that here so
9/// `attachment.is_some()` is the single source of truth.
10#[derive(Debug, Clone, Serialize)]
11pub struct Post {
12    /// Post number.
13    pub no: u64,
14    /// Reply-to. `0` for OP, OP number for replies.
15    pub resto: u64,
16    /// Unix timestamp of post creation.
17    pub time: i64,
18    /// Pre-formatted `MM/DD/YY(Day)HH:MM` timestamp.
19    pub now: String,
20    /// Poster name. Defaults to `"Anonymous"`.
21    pub name: String,
22    /// OP-only subject.
23    pub sub: Option<String>,
24    /// HTML-escaped comment body.
25    pub com: Option<String>,
26    /// Tripcode (`!tripcode` or `!!securetrip`).
27    pub trip: Option<String>,
28    /// 8-character poster ID, for boards that show one.
29    pub id: Option<String>,
30    /// `mod`, `admin`, `developer`, etc.
31    pub capcode: Option<String>,
32    /// ISO 3166-1 alpha-2 country code.
33    pub country: Option<String>,
34    pub country_name: Option<String>,
35    pub board_flag: Option<String>,
36    pub flag_name: Option<String>,
37    /// 4-digit year the poster bought a 4chan Pass.
38    pub since4pass: Option<u32>,
39
40    // OP-only.
41    pub sticky: bool,
42    pub closed: bool,
43    pub archived: bool,
44    pub archived_on: Option<i64>,
45    pub bumplimit: bool,
46    pub imagelimit: bool,
47    pub replies: Option<u32>,
48    pub images: Option<u32>,
49    pub unique_ips: Option<u32>,
50    pub semantic_url: Option<String>,
51    pub tag: Option<String>,
52
53    pub attachment: Option<Attachment>,
54}
55
56/// File attachment metadata.
57#[derive(Debug, Clone, Serialize)]
58pub struct Attachment {
59    pub id: u64,            /// Unixtime + microtime upload identifier; this is afterall the filename on 4chan CDN.
60    pub filename: String,   /// Original filename the user uploaded (no extension).
61    pub ext: String,        /// Extension, including the leading dot (e.g. `".jpg"`). 
62    pub size: u64,          /// Size in bytes.
63    pub md5: String,        /// 24-character base64-packed MD5.
64    pub width: i32,
65    pub height: i32,
66    pub thumbnail_width: i32,
67    pub thumbnail_height: i32,
68    pub spoiler: bool,
69    pub custom_spoiler: Option<u8>,
70    pub deleted: bool,
71    pub mobile_optimized: bool,
72}
73
74impl Attachment {
75    /// Full-resolution URL on `i.4cdn.org`.
76    pub fn url(&self, board: &str) -> String {
77        format!("https://i.4cdn.org/{}/{}{}", board, self.id, self.ext)
78    }
79
80    /// Thumbnail URL. Always a compressed JPEG, regardless of the original extension.
81    pub fn thumbnail_url(&self, board: &str) -> String {
82        format!("https://i.4cdn.org/{}/{}s.jpg", board, self.id)
83    }
84
85    pub fn is_video(&self) -> bool {
86        matches!(
87            self.ext.to_lowercase().as_str(),
88            ".webm" | ".mp4" | ".mov" | ".mkv"
89        )
90    }
91
92    pub fn is_animated(&self) -> bool {
93        self.is_video() || self.ext.eq_ignore_ascii_case(".gif")
94    }
95
96    /// True for still-image extensions. 
97    pub fn is_image(&self) -> bool {
98        matches!(
99            self.ext.to_lowercase().as_str(),
100            ".jpg" | ".jpeg" | ".png" | ".gif" | ".webp"
101        )
102    }
103}
104
105impl Post {
106    /// True if this post is the thread OP. Replies carry `resto = OP number`.
107    pub fn is_op(&self) -> bool {
108        self.resto == 0
109    }
110}
111
112impl Post {
113    pub(crate) fn from_raw(raw: RawPost) -> Self {
114        let attachment = raw.tim.and_then(|id| {
115            let ext = raw.ext?;
116            Some(Attachment {
117                id,
118                filename: raw.filename.unwrap_or_default(),
119                ext,
120                size: raw.fsize.unwrap_or(0),
121                md5: raw.md5.unwrap_or_default(),
122                width: raw.w.unwrap_or(0),
123                height: raw.h.unwrap_or(0),
124                thumbnail_width: raw.tn_w.unwrap_or(0),
125                thumbnail_height: raw.tn_h.unwrap_or(0),
126                spoiler: raw.spoiler.unwrap_or(0) == 1,
127                custom_spoiler: raw.custom_spoiler,
128                deleted: raw.filedeleted.unwrap_or(0) == 1,
129                mobile_optimized: raw.m_img.unwrap_or(0) == 1,
130            })
131        });
132
133        Post {
134            no: raw.no,
135            resto: raw.resto,
136            time: raw.time,
137            now: raw.now,
138            name: raw.name.unwrap_or_else(|| "Anonymous".to_string()),
139            sub: raw.sub,
140            com: raw.com,
141            trip: raw.trip,
142            id: raw.id,
143            capcode: raw.capcode,
144            country: raw.country,
145            country_name: raw.country_name,
146            board_flag: raw.board_flag,
147            flag_name: raw.flag_name,
148            since4pass: raw.since4pass,
149            sticky: raw.sticky.unwrap_or(0) == 1,
150            closed: raw.closed.unwrap_or(0) == 1,
151            archived: raw.archived.unwrap_or(0) == 1,
152            archived_on: raw.archived_on,
153            bumplimit: raw.bumplimit.unwrap_or(0) == 1,
154            imagelimit: raw.imagelimit.unwrap_or(0) == 1,
155            replies: raw.replies,
156            images: raw.images,
157            unique_ips: raw.unique_ips,
158            semantic_url: raw.semantic_url,
159            tag: raw.tag,
160            attachment,
161        }
162    }
163}
164
165impl<'de> Deserialize<'de> for Post {
166    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167    where
168        D: serde::Deserializer<'de>,
169    {
170        RawPost::deserialize(deserializer).map(Post::from_raw)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn op_without_file_normalizes() {
180        let json = r#"{"no":1,"resto":0,"time":0,"now":"x"}"#;
181        let p: Post = serde_json::from_str(json).unwrap();
182        assert!(p.is_op());
183        assert_eq!(p.name, "Anonymous"); // defaulted anon
184        assert!(p.attachment.is_none());
185    }
186
187    #[test]
188    fn reply_attachment_urls_and_kind() {
189        let json = r#"{"no":2,"resto":1,"time":0,"now":"x","tim":1234,"ext":".png",
190                       "filename":"f","fsize":10,"md5":"abc","w":800,"h":600,
191                       "tn_w":250,"tn_h":187}"#;
192        let p: Post = serde_json::from_str(json).unwrap();
193        assert!(!p.is_op());
194        let a = p.attachment.as_ref().expect("attachment present when tim+ext set");
195        assert_eq!(a.url("g"), "https://i.4cdn.org/g/1234.png");
196        assert_eq!(a.thumbnail_url("g"), "https://i.4cdn.org/g/1234s.jpg");
197        assert!(a.is_image());
198        assert!(!a.is_video());
199    }
200
201    #[test]
202    fn webm_is_video_not_image() {
203        let json = r#"{"no":3,"resto":1,"time":0,"now":"x","tim":9,"ext":".webm"}"#;
204        let p: Post = serde_json::from_str(json).unwrap();
205        let a = p.attachment.unwrap();
206        assert!(a.is_video());
207        assert!(a.is_animated());
208        assert!(!a.is_image());
209    }
210}