Skip to main content

dsc/api/
users.rs

1use super::client::DiscourseClient;
2use super::error::http_error;
3use anyhow::{Context, Result, anyhow};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// One row from /admin/users/list/<type>.json.
8///
9/// `id` is signed: Discourse's built-in system accounts use negative IDs
10/// (`system` is `-1`, `discobot` is `-2`), and these appear in the
11/// `active` listing. See `spec/user-list-negative-ids.md`.
12#[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/// Distilled /users/<username>.json payload.
37///
38/// `id` is signed for the same reason as `UserSummary::id`.
39#[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    /// List users via the admin users endpoint.
69    ///
70    /// `listing` is one of: `active` (default), `new`, `staff`, `suspended`,
71    /// `silenced`, `staged`. Discourse paginates 100 per page.
72    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    /// Look up a user by username (public endpoint).
89    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    /// Fetch the full admin view of a user (`GET /admin/users/{id}.json`) as
107    /// raw JSON. This carries the complete PII surface - all emails,
108    /// registration/last IP addresses, custom fields, associated accounts -
109    /// that the public `/u/{username}.json` (used by `fetch_user_detail`)
110    /// omits. The cornerstone of a Subject Access Request export.
111    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    /// Search users by a free-text filter (username, name, or email) via
125    /// `GET /admin/users/list/all.json?filter=…`. Used to resolve an email
126    /// address to an account.
127    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    /// Suspend a user by ID. `until` is an ISO-8601 timestamp (or any string
142    /// Discourse accepts, like "forever"); `reason` is mandatory from the UI
143    /// but Discourse accepts empty via the API.
144    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    /// Unsuspend a user by ID.
150    pub fn unsuspend_user(&self, user_id: i64) -> Result<()> {
151        self.put_admin_user_action(user_id, "unsuspend", &[], "unsuspend user request")
152    }
153
154    /// Silence a user by ID. Optional `silenced_till` (Discourse-accepted
155    /// timestamp string) and `reason`; both default to empty.
156    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    /// Unsilence a user by ID.
168    pub fn unsilence_user(&self, user_id: i64) -> Result<()> {
169        self.put_admin_user_action(user_id, "unsilence", &[], "unsilence user request")
170    }
171
172    /// Grant admin to a user.
173    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    /// Revoke admin from a user.
178    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    /// Grant moderator to a user.
183    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    /// Revoke moderator from a user.
188    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    /// Create a user. `password` is optional — omit to require the new user
198    /// to reset it via the email flow. `active=true` and `approved=true` are
199    /// passed so admin-created accounts skip the activation and approval
200    /// dances. Returns the new user id on success.
201    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        // Discourse wraps this variably depending on version; grab user_id from
230        // the top level first, then fall back to `user.id`.
231        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    /// Trigger the "forgot password" email flow for a user. Accepts username
245    /// or email as `login`. Discourse returns a generic success message
246    /// regardless of whether the user exists (to prevent enumeration).
247    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    /// Admin-set a user's primary email address.
262    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
303/// Percent-encode a query-string value per RFC 3986 (unreserved set passes
304/// through). Keeps an email's `@` / `+` from being mangled when used as a
305/// `filter=` value.
306fn 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    /// Regression test for the bug captured in
333    /// `spec/user-list-negative-ids.md`: Discourse uses negative IDs for
334    /// its built-in `system` (-1) and `discobot` (-2) accounts, which
335    /// appear in the `active` listing. Parsing a page that contains them
336    /// must succeed, not fail with `invalid value: integer -2`.
337    #[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}