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 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 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}