1use super::client::DiscourseClient;
2use super::error::http_error;
3use anyhow::{Context, Result, anyhow};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Deserialize, Serialize, Clone)]
13pub struct UserSummary {
14 pub id: i64,
15 pub username: String,
16 #[serde(default)]
17 pub name: Option<String>,
18 #[serde(default)]
19 pub email: Option<String>,
20 #[serde(default)]
21 pub trust_level: Option<u64>,
22 #[serde(default)]
23 pub admin: Option<bool>,
24 #[serde(default)]
25 pub moderator: Option<bool>,
26 #[serde(default)]
27 pub suspended: Option<bool>,
28 #[serde(default)]
29 pub silenced: Option<bool>,
30 #[serde(default)]
31 pub last_seen_at: Option<String>,
32 #[serde(default)]
33 pub created_at: Option<String>,
34}
35
36#[derive(Debug, Deserialize, Serialize, Clone)]
40pub struct UserDetail {
41 pub id: i64,
42 pub username: String,
43 #[serde(default)]
44 pub name: Option<String>,
45 #[serde(default)]
46 pub email: Option<String>,
47 #[serde(default)]
48 pub trust_level: Option<u64>,
49 #[serde(default)]
50 pub admin: Option<bool>,
51 #[serde(default)]
52 pub moderator: Option<bool>,
53 #[serde(default)]
54 pub suspended_till: Option<String>,
55 #[serde(default)]
56 pub silenced_till: Option<String>,
57 #[serde(default)]
58 pub last_seen_at: Option<String>,
59 #[serde(default)]
60 pub created_at: Option<String>,
61 #[serde(default)]
62 pub post_count: Option<u64>,
63 #[serde(default)]
64 pub groups: Vec<Value>,
65}
66
67impl DiscourseClient {
68 pub fn admin_list_users(&self, listing: &str, page: u32) -> Result<Vec<UserSummary>> {
73 let path = format!(
74 "/admin/users/list/{}.json?show_emails=true&page={}",
75 listing, page
76 );
77 let response = self.get(&path)?;
78 let status = response.status();
79 let text = response.text().context("reading user list response")?;
80 if !status.is_success() {
81 return Err(http_error("admin user list request", status, &text));
82 }
83 let users: Vec<UserSummary> =
84 serde_json::from_str(&text).context("parsing user list response")?;
85 Ok(users)
86 }
87
88 pub fn fetch_user_detail(&self, username: &str) -> Result<UserDetail> {
90 let path = format!("/u/{}.json", username);
91 let response = self.get(&path)?;
92 let status = response.status();
93 let text = response.text().context("reading user detail response")?;
94 if !status.is_success() {
95 return Err(http_error("user detail request", status, &text));
96 }
97 let value: Value = serde_json::from_str(&text).context("parsing user detail response")?;
98 let user = value
99 .get("user")
100 .ok_or_else(|| anyhow!("user detail response missing `user` field"))?;
101 let detail: UserDetail =
102 serde_json::from_value(user.clone()).context("deserialising user detail")?;
103 Ok(detail)
104 }
105
106 pub fn fetch_admin_user_detail(&self, user_id: i64) -> Result<Value> {
112 let path = format!("/admin/users/{}.json", user_id);
113 let response = self.get(&path)?;
114 let status = response.status();
115 let text = response
116 .text()
117 .context("reading admin user detail response")?;
118 if !status.is_success() {
119 return Err(http_error("admin user detail request", status, &text));
120 }
121 serde_json::from_str(&text).context("parsing admin user detail response")
122 }
123
124 pub fn admin_search_users(&self, query: &str) -> Result<Vec<UserSummary>> {
128 let path = format!(
129 "/admin/users/list/all.json?show_emails=true&filter={}",
130 encode_query_value(query)
131 );
132 let response = self.get(&path)?;
133 let status = response.status();
134 let text = response.text().context("reading user search response")?;
135 if !status.is_success() {
136 return Err(http_error("admin user search request", status, &text));
137 }
138 serde_json::from_str(&text).context("parsing user search response")
139 }
140
141 pub fn suspend_user(&self, user_id: i64, until: &str, reason: &str) -> Result<()> {
145 let payload = [("suspend_until", until), ("reason", reason)];
146 self.put_admin_user_action(user_id, "suspend", &payload, "suspend user request")
147 }
148
149 pub fn unsuspend_user(&self, user_id: i64) -> Result<()> {
151 self.put_admin_user_action(user_id, "unsuspend", &[], "unsuspend user request")
152 }
153
154 pub fn silence_user(&self, user_id: i64, until: &str, reason: &str) -> Result<()> {
157 let mut payload: Vec<(&str, &str)> = Vec::new();
158 if !until.is_empty() {
159 payload.push(("silenced_till", until));
160 }
161 if !reason.is_empty() {
162 payload.push(("reason", reason));
163 }
164 self.put_admin_user_action(user_id, "silence", &payload, "silence user request")
165 }
166
167 pub fn unsilence_user(&self, user_id: i64) -> Result<()> {
169 self.put_admin_user_action(user_id, "unsilence", &[], "unsilence user request")
170 }
171
172 pub fn grant_admin(&self, user_id: i64) -> Result<()> {
174 self.put_admin_user_action(user_id, "grant_admin", &[], "grant admin request")
175 }
176
177 pub fn revoke_admin(&self, user_id: i64) -> Result<()> {
179 self.put_admin_user_action(user_id, "revoke_admin", &[], "revoke admin request")
180 }
181
182 pub fn grant_moderation(&self, user_id: i64) -> Result<()> {
184 self.put_admin_user_action(user_id, "grant_moderation", &[], "grant moderation request")
185 }
186
187 pub fn revoke_moderation(&self, user_id: i64) -> Result<()> {
189 self.put_admin_user_action(
190 user_id,
191 "revoke_moderation",
192 &[],
193 "revoke moderation request",
194 )
195 }
196
197 pub fn create_user(
202 &self,
203 email: &str,
204 username: &str,
205 password: Option<&str>,
206 name: Option<&str>,
207 approve: bool,
208 ) -> Result<i64> {
209 let mut payload: Vec<(&str, &str)> =
210 vec![("email", email), ("username", username), ("active", "true")];
211 if approve {
212 payload.push(("approved", "true"));
213 }
214 if let Some(p) = password {
215 payload.push(("password", p));
216 }
217 if let Some(n) = name
218 && !n.is_empty()
219 {
220 payload.push(("name", n));
221 }
222 let response = self.send_retrying(|| Ok(self.post("/u.json")?.form(&payload)))?;
223 let status = response.status();
224 let text = response.text().context("reading user create response")?;
225 if !status.is_success() {
226 return Err(http_error("user create request", status, &text));
227 }
228 let value: Value = serde_json::from_str(&text).context("parsing user create response")?;
229 let id = value
232 .get("user_id")
233 .and_then(|v| v.as_i64())
234 .or_else(|| {
235 value
236 .get("user")
237 .and_then(|u| u.get("id"))
238 .and_then(|v| v.as_i64())
239 })
240 .ok_or_else(|| anyhow!("user create response missing user id: {}", text))?;
241 Ok(id)
242 }
243
244 pub fn trigger_password_reset(&self, login: &str) -> Result<()> {
248 let payload = [("login", login)];
249 let response =
250 self.send_retrying(|| Ok(self.post("/session/forgot_password.json")?.form(&payload)))?;
251 let status = response.status();
252 if !status.is_success() {
253 let text = response
254 .text()
255 .unwrap_or_else(|_| "<failed to read response body>".to_string());
256 return Err(http_error("password reset request", status, &text));
257 }
258 Ok(())
259 }
260
261 pub fn set_user_email(&self, username: &str, email: &str) -> Result<()> {
263 let path = format!("/u/{}/preferences/email.json", username);
264 let payload = [("email", email)];
265 let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
266 let status = response.status();
267 if !status.is_success() {
268 let text = response
269 .text()
270 .unwrap_or_else(|_| "<failed to read response body>".to_string());
271 return Err(http_error("email set request", status, &text));
272 }
273 Ok(())
274 }
275
276 fn put_admin_user_action(
277 &self,
278 user_id: i64,
279 action: &str,
280 payload: &[(&str, &str)],
281 action_label: &str,
282 ) -> Result<()> {
283 let path = format!("/admin/users/{}/{}.json", user_id, action);
284 let response = self.send_retrying(|| {
285 let rb = self.put(&path)?;
286 Ok(if payload.is_empty() {
287 rb
288 } else {
289 rb.form(payload)
290 })
291 })?;
292 let status = response.status();
293 if !status.is_success() {
294 let text = response
295 .text()
296 .unwrap_or_else(|_| "<failed to read response body>".to_string());
297 return Err(http_error(action_label, status, &text));
298 }
299 Ok(())
300 }
301}
302
303fn encode_query_value(s: &str) -> String {
307 let mut out = String::with_capacity(s.len());
308 for b in s.bytes() {
309 match b {
310 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
311 out.push(b as char)
312 }
313 _ => out.push_str(&format!("%{:02X}", b)),
314 }
315 }
316 out
317}
318
319#[cfg(test)]
320mod tests {
321 use super::{UserDetail, UserSummary, encode_query_value};
322
323 #[test]
324 fn encode_query_value_escapes_email_specials() {
325 assert_eq!(
326 encode_query_value("jane+tag@example.com"),
327 "jane%2Btag%40example.com"
328 );
329 assert_eq!(encode_query_value("simple-name_1"), "simple-name_1");
330 }
331
332 #[test]
338 fn user_summary_accepts_negative_system_ids() {
339 let json = r#"[
340 {"id": -1, "username": "system", "name": "system",
341 "email": "no_email"},
342 {"id": -2, "username": "discobot", "name": "discobot",
343 "email": "no_email"},
344 {"id": 42, "username": "alice", "name": "Alice",
345 "email": "alice@example.com"}
346 ]"#;
347 let users: Vec<UserSummary> = serde_json::from_str(json).expect("negative ids must parse");
348 assert_eq!(users.len(), 3);
349 assert_eq!(users[0].id, -1);
350 assert_eq!(users[1].id, -2);
351 assert_eq!(users[2].id, 42);
352 assert_eq!(users[0].username, "system");
353 }
354
355 #[test]
356 fn user_detail_accepts_negative_system_ids() {
357 let json = r#"{"id": -1, "username": "system"}"#;
358 let detail: UserDetail = serde_json::from_str(json).expect("must parse");
359 assert_eq!(detail.id, -1);
360 assert_eq!(detail.username, "system");
361 }
362}