Skip to main content

dsc/api/
emoji.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use super::models::CustomEmoji;
4use anyhow::{Context, Result, anyhow};
5use reqwest::StatusCode;
6use serde_json::Value;
7use std::path::Path;
8
9impl DiscourseClient {
10    /// Upload a custom emoji.
11    pub fn upload_emoji(&self, emoji_path: &Path, emoji_name: &str) -> Result<()> {
12        let make_form_legacy = || -> Result<reqwest::blocking::multipart::Form> {
13            let file = std::fs::read(emoji_path)
14                .with_context(|| format!("reading {}", emoji_path.display()))?;
15            let part = reqwest::blocking::multipart::Part::bytes(file)
16                .file_name(
17                    emoji_path
18                        .file_name()
19                        .and_then(|s| s.to_str())
20                        .unwrap_or("emoji.png")
21                        .to_string(),
22                )
23                .mime_str("image/png")
24                .context("setting emoji mime")?;
25            Ok(reqwest::blocking::multipart::Form::new()
26                .part("emoji[image]", part)
27                .text("emoji[name]", emoji_name.to_string()))
28        };
29
30        let make_form_v2 = || -> Result<reqwest::blocking::multipart::Form> {
31            let file = std::fs::read(emoji_path)
32                .with_context(|| format!("reading {}", emoji_path.display()))?;
33            let part = reqwest::blocking::multipart::Part::bytes(file)
34                .file_name(
35                    emoji_path
36                        .file_name()
37                        .and_then(|s| s.to_str())
38                        .unwrap_or("emoji.png")
39                        .to_string(),
40                )
41                .mime_str("image/png")
42                .context("setting emoji mime")?;
43            Ok(reqwest::blocking::multipart::Form::new()
44                .part("file", part)
45                .text("name", emoji_name.to_string()))
46        };
47
48        let upload_v2_json_path = emoji_admin_path("/admin/config/emoji.json");
49        let upload_json_path = emoji_admin_path("/admin/customize/emojis.json");
50        let upload_path = emoji_admin_path("/admin/customize/emojis");
51
52        let mut response = self
53            .post(&upload_v2_json_path)?
54            .multipart(make_form_v2()?)
55            .send()
56            .context("uploading emoji")?;
57        if response.status() == StatusCode::NOT_FOUND {
58            response = self
59                .post(&upload_json_path)?
60                .multipart(make_form_legacy()?)
61                .send()
62                .context("uploading emoji")?;
63        }
64        if response.status() == StatusCode::NOT_FOUND {
65            response = self
66                .post(&upload_path)?
67                .multipart(make_form_legacy()?)
68                .send()
69                .context("uploading emoji")?;
70        }
71        if !response.status().is_success() {
72            let status = response.status();
73            if matches!(
74                status,
75                StatusCode::NOT_FOUND | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED
76            ) {
77                return Err(anyhow!(
78                    "emoji upload failed with {} (requires an admin API key)",
79                    status
80                ));
81            }
82            let text = response
83                .text()
84                .unwrap_or_else(|_| "<failed to read response body>".to_string());
85            return Err(anyhow!("emoji upload failed with {}: {}", status, text));
86        }
87        Ok(())
88    }
89
90    /// List custom emojis.
91    pub fn list_custom_emojis(&self) -> Result<Vec<CustomEmoji>> {
92        if let Some(emojis) = self.list_admin_emojis()? {
93            return Ok(emojis);
94        }
95        if let Some(emojis) = self.list_admin_config_emojis()? {
96            return Ok(emojis);
97        }
98        self.list_public_emojis()
99    }
100
101    fn list_admin_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
102        let path = emoji_admin_path("/admin/customize/emojis.json");
103        let response = self.get(&path)?;
104        let status = response.status();
105        let text = response.text().context("reading emoji list response")?;
106        if !status.is_success() {
107            if status == StatusCode::NOT_FOUND {
108                return Ok(None);
109            }
110            return Err(http_error("emoji list request", status, &text));
111        }
112        let value: Value = serde_json::from_str(&text).context("parsing emoji list json")?;
113        let emojis = if let Some(arr) = value.as_array() {
114            extract_emojis_from_array(arr, self.baseurl())
115        } else if let Some(val) = value.get("emojis") {
116            extract_emojis_from_value(val, self.baseurl())
117        } else if let Some(val) = value.get("custom_emoji") {
118            extract_emojis_from_value(val, self.baseurl())
119        } else if let Some(val) = value.get("custom") {
120            extract_emojis_from_value(val, self.baseurl())
121        } else if let Some(map) = value.as_object() {
122            let mut out = Vec::new();
123            extract_emojis_from_map(map, self.baseurl(), &mut out);
124            out
125        } else {
126            Vec::new()
127        };
128        Ok(Some(emojis))
129    }
130
131    fn list_public_emojis(&self) -> Result<Vec<CustomEmoji>> {
132        let response = self.get("/emoji.json")?;
133        let status = response.status();
134        let text = response.text().context("reading emoji.json response")?;
135        if status == StatusCode::NOT_FOUND {
136            return Ok(Vec::new());
137        }
138        if !status.is_success() {
139            return Err(http_error("emoji.json request", status, &text));
140        }
141        let value: Value = serde_json::from_str(&text).context("parsing emoji.json")?;
142        let baseurl = self.baseurl().trim_end_matches('/');
143        let mut out = Vec::new();
144        if let Some(val) = value.get("custom_emoji") {
145            out.extend(extract_emojis_from_value(val, baseurl));
146        }
147        if let Some(val) = value.get("custom") {
148            out.extend(extract_emojis_from_value(val, baseurl));
149        }
150        if out.is_empty() {
151            if let Some(val) = value.get("emoji") {
152                out.extend(extract_emojis_from_value(val, baseurl));
153            }
154        }
155        Ok(out)
156    }
157
158    fn list_admin_config_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
159        let path = emoji_admin_path("/admin/config/emoji.json");
160        let response = self.get(&path)?;
161        let status = response.status();
162        let text = response
163            .text()
164            .context("reading admin config emoji response")?;
165        if status == StatusCode::NOT_FOUND {
166            return Ok(None);
167        }
168        if !status.is_success() {
169            return Err(http_error("admin config emoji request", status, &text));
170        }
171        let value: Value =
172            serde_json::from_str(&text).context("parsing admin config emoji json")?;
173        if let Some(val) = value.get("emojis") {
174            return Ok(Some(extract_emojis_from_value(val, self.baseurl())));
175        }
176        Ok(Some(extract_emojis_from_value(&value, self.baseurl())))
177    }
178}
179
180fn emoji_admin_path(path: &str) -> String {
181    let client_id = match std::env::var("DSC_EMOJI_CLIENT_ID") {
182        Ok(value) => value,
183        Err(_) => return path.to_string(),
184    };
185    let client_id = client_id.trim();
186    if client_id.is_empty() || path.contains("client_id=") {
187        return path.to_string();
188    }
189    let sep = if path.contains('?') { '&' } else { '?' };
190    format!("{path}{sep}client_id={client_id}")
191}
192
193fn extract_emojis_from_array(emojis: &[Value], baseurl: &str) -> Vec<CustomEmoji> {
194    let mut out = Vec::new();
195    for item in emojis.iter() {
196        let name = item.get("name").and_then(|v| v.as_str());
197        let url = item
198            .get("url")
199            .and_then(|v| v.as_str())
200            .or_else(|| item.get("image_url").and_then(|v| v.as_str()));
201        if let (Some(name), Some(url)) = (name, url) {
202            out.push(CustomEmoji {
203                name: name.to_string(),
204                url: normalize_emoji_url(baseurl, url),
205            });
206        }
207    }
208    out
209}
210
211fn extract_emojis_from_map(
212    map: &serde_json::Map<String, Value>,
213    baseurl: &str,
214    out: &mut Vec<CustomEmoji>,
215) {
216    for (name, value) in map.iter() {
217        let url = value
218            .as_str()
219            .or_else(|| value.get("url").and_then(|v| v.as_str()))
220            .or_else(|| value.get("image_url").and_then(|v| v.as_str()))
221            .or_else(|| value.get("path").and_then(|v| v.as_str()));
222        if let Some(url) = url {
223            out.push(CustomEmoji {
224                name: name.to_string(),
225                url: normalize_emoji_url(baseurl, url),
226            });
227        }
228    }
229}
230
231fn extract_emojis_from_value(value: &Value, baseurl: &str) -> Vec<CustomEmoji> {
232    match value {
233        Value::Array(arr) => extract_emojis_from_array(arr, baseurl),
234        Value::Object(map) => {
235            let name = map.get("name").and_then(|v| v.as_str());
236            let url = map
237                .get("url")
238                .and_then(|v| v.as_str())
239                .or_else(|| map.get("image_url").and_then(|v| v.as_str()))
240                .or_else(|| map.get("path").and_then(|v| v.as_str()));
241            if let (Some(name), Some(url)) = (name, url) {
242                return vec![CustomEmoji {
243                    name: name.to_string(),
244                    url: normalize_emoji_url(baseurl, url),
245                }];
246            }
247            let mut out = Vec::new();
248            extract_emojis_from_map(map, baseurl, &mut out);
249            out
250        }
251        _ => Vec::new(),
252    }
253}
254
255fn normalize_emoji_url(baseurl: &str, url: &str) -> String {
256    if url.starts_with("http://") || url.starts_with("https://") {
257        url.to_string()
258    } else if url.starts_with("//") {
259        let scheme = if baseurl.starts_with("http://") {
260            "http:"
261        } else {
262            "https:"
263        };
264        format!("{}{}", scheme, url)
265    } else if url.starts_with('/') {
266        format!("{}{}", baseurl, url)
267    } else {
268        format!("{}/{}", baseurl, url)
269    }
270}