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 .send_retrying(|| Ok(self.post(&upload_v2_json_path)?.multipart(make_form_v2()?)))?;
54 if response.status() == StatusCode::NOT_FOUND {
55 response = self.send_retrying(|| {
56 Ok(self.post(&upload_json_path)?.multipart(make_form_legacy()?))
57 })?;
58 }
59 if response.status() == StatusCode::NOT_FOUND {
60 response = self
61 .send_retrying(|| Ok(self.post(&upload_path)?.multipart(make_form_legacy()?)))?;
62 }
63 if !response.status().is_success() {
64 let status = response.status();
65 if matches!(
66 status,
67 StatusCode::NOT_FOUND | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED
68 ) {
69 return Err(anyhow!(
70 "emoji upload failed with {} (requires an admin API key)",
71 status
72 ));
73 }
74 let text = response
75 .text()
76 .unwrap_or_else(|_| "<failed to read response body>".to_string());
77 return Err(anyhow!("emoji upload failed with {}: {}", status, text));
78 }
79 Ok(())
80 }
81
82 pub fn list_custom_emojis(&self) -> Result<Vec<CustomEmoji>> {
84 if let Some(emojis) = self.list_admin_emojis()? {
85 return Ok(emojis);
86 }
87 if let Some(emojis) = self.list_admin_config_emojis()? {
88 return Ok(emojis);
89 }
90 self.list_public_emojis()
91 }
92
93 fn list_admin_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
94 let path = emoji_admin_path("/admin/customize/emojis.json");
95 let response = self.get(&path)?;
96 let status = response.status();
97 let text = response.text().context("reading emoji list response")?;
98 if !status.is_success() {
99 if status == StatusCode::NOT_FOUND {
100 return Ok(None);
101 }
102 return Err(http_error("emoji list request", status, &text));
103 }
104 let value: Value = serde_json::from_str(&text).context("parsing emoji list json")?;
105 let emojis = if let Some(arr) = value.as_array() {
106 extract_emojis_from_array(arr, self.baseurl())
107 } else if let Some(val) = value.get("emojis") {
108 extract_emojis_from_value(val, self.baseurl())
109 } else if let Some(val) = value.get("custom_emoji") {
110 extract_emojis_from_value(val, self.baseurl())
111 } else if let Some(val) = value.get("custom") {
112 extract_emojis_from_value(val, self.baseurl())
113 } else if let Some(map) = value.as_object() {
114 let mut out = Vec::new();
115 extract_emojis_from_map(map, self.baseurl(), &mut out);
116 out
117 } else {
118 Vec::new()
119 };
120 Ok(Some(emojis))
121 }
122
123 fn list_public_emojis(&self) -> Result<Vec<CustomEmoji>> {
124 let response = self.get("/emoji.json")?;
125 let status = response.status();
126 let text = response.text().context("reading emoji.json response")?;
127 if status == StatusCode::NOT_FOUND {
128 return Ok(Vec::new());
129 }
130 if !status.is_success() {
131 return Err(http_error("emoji.json request", status, &text));
132 }
133 let value: Value = serde_json::from_str(&text).context("parsing emoji.json")?;
134 let baseurl = self.baseurl().trim_end_matches('/');
135 let mut out = Vec::new();
136 if let Some(val) = value.get("custom_emoji") {
137 out.extend(extract_emojis_from_value(val, baseurl));
138 }
139 if let Some(val) = value.get("custom") {
140 out.extend(extract_emojis_from_value(val, baseurl));
141 }
142 if out.is_empty() {
143 if let Some(val) = value.get("emoji") {
144 out.extend(extract_emojis_from_value(val, baseurl));
145 }
146 }
147 Ok(out)
148 }
149
150 fn list_admin_config_emojis(&self) -> Result<Option<Vec<CustomEmoji>>> {
151 let path = emoji_admin_path("/admin/config/emoji.json");
152 let response = self.get(&path)?;
153 let status = response.status();
154 let text = response
155 .text()
156 .context("reading admin config emoji response")?;
157 if status == StatusCode::NOT_FOUND {
158 return Ok(None);
159 }
160 if !status.is_success() {
161 return Err(http_error("admin config emoji request", status, &text));
162 }
163 let value: Value =
164 serde_json::from_str(&text).context("parsing admin config emoji json")?;
165 if let Some(val) = value.get("emojis") {
166 return Ok(Some(extract_emojis_from_value(val, self.baseurl())));
167 }
168 Ok(Some(extract_emojis_from_value(&value, self.baseurl())))
169 }
170}
171
172fn emoji_admin_path(path: &str) -> String {
173 let client_id = match std::env::var("DSC_EMOJI_CLIENT_ID") {
174 Ok(value) => value,
175 Err(_) => return path.to_string(),
176 };
177 let client_id = client_id.trim();
178 if client_id.is_empty() || path.contains("client_id=") {
179 return path.to_string();
180 }
181 let sep = if path.contains('?') { '&' } else { '?' };
182 format!("{path}{sep}client_id={client_id}")
183}
184
185fn extract_emojis_from_array(emojis: &[Value], baseurl: &str) -> Vec<CustomEmoji> {
186 let mut out = Vec::new();
187 for item in emojis.iter() {
188 let name = item.get("name").and_then(|v| v.as_str());
189 let url = item
190 .get("url")
191 .and_then(|v| v.as_str())
192 .or_else(|| item.get("image_url").and_then(|v| v.as_str()));
193 if let (Some(name), Some(url)) = (name, url) {
194 out.push(CustomEmoji {
195 name: name.to_string(),
196 url: normalize_emoji_url(baseurl, url),
197 });
198 }
199 }
200 out
201}
202
203fn extract_emojis_from_map(
204 map: &serde_json::Map<String, Value>,
205 baseurl: &str,
206 out: &mut Vec<CustomEmoji>,
207) {
208 for (name, value) in map.iter() {
209 let url = value
210 .as_str()
211 .or_else(|| value.get("url").and_then(|v| v.as_str()))
212 .or_else(|| value.get("image_url").and_then(|v| v.as_str()))
213 .or_else(|| value.get("path").and_then(|v| v.as_str()));
214 if let Some(url) = url {
215 out.push(CustomEmoji {
216 name: name.to_string(),
217 url: normalize_emoji_url(baseurl, url),
218 });
219 }
220 }
221}
222
223fn extract_emojis_from_value(value: &Value, baseurl: &str) -> Vec<CustomEmoji> {
224 match value {
225 Value::Array(arr) => extract_emojis_from_array(arr, baseurl),
226 Value::Object(map) => {
227 let name = map.get("name").and_then(|v| v.as_str());
228 let url = map
229 .get("url")
230 .and_then(|v| v.as_str())
231 .or_else(|| map.get("image_url").and_then(|v| v.as_str()))
232 .or_else(|| map.get("path").and_then(|v| v.as_str()));
233 if let (Some(name), Some(url)) = (name, url) {
234 return vec![CustomEmoji {
235 name: name.to_string(),
236 url: normalize_emoji_url(baseurl, url),
237 }];
238 }
239 let mut out = Vec::new();
240 extract_emojis_from_map(map, baseurl, &mut out);
241 out
242 }
243 _ => Vec::new(),
244 }
245}
246
247fn normalize_emoji_url(baseurl: &str, url: &str) -> String {
248 if url.starts_with("http://") || url.starts_with("https://") {
249 url.to_string()
250 } else if url.starts_with("//") {
251 let scheme = if baseurl.starts_with("http://") {
252 "http:"
253 } else {
254 "https:"
255 };
256 format!("{}{}", scheme, url)
257 } else if url.starts_with('/') {
258 format!("{}{}", baseurl, url)
259 } else {
260 format!("{}/{}", baseurl, url)
261 }
262}
263