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 =
98            serde_json::from_str(&text).context("parsing user detail response")?;
99        let user = value
100            .get("user")
101            .ok_or_else(|| anyhow!("user detail response missing `user` field"))?;
102        let detail: UserDetail =
103            serde_json::from_value(user.clone()).context("deserialising user detail")?;
104        Ok(detail)
105    }
106
107    /// Suspend a user by ID. `until` is an ISO-8601 timestamp (or any string
108    /// Discourse accepts, like "forever"); `reason` is mandatory from the UI
109    /// but Discourse accepts empty via the API.
110    pub fn suspend_user(&self, user_id: i64, until: &str, reason: &str) -> Result<()> {
111        let payload = [("suspend_until", until), ("reason", reason)];
112        self.put_admin_user_action(user_id, "suspend", &payload, "suspend user request")
113    }
114
115    /// Unsuspend a user by ID.
116    pub fn unsuspend_user(&self, user_id: i64) -> Result<()> {
117        self.put_admin_user_action(user_id, "unsuspend", &[], "unsuspend user request")
118    }
119
120    /// Silence a user by ID. Optional `silenced_till` (Discourse-accepted
121    /// timestamp string) and `reason`; both default to empty.
122    pub fn silence_user(&self, user_id: i64, until: &str, reason: &str) -> Result<()> {
123        let mut payload: Vec<(&str, &str)> = Vec::new();
124        if !until.is_empty() {
125            payload.push(("silenced_till", until));
126        }
127        if !reason.is_empty() {
128            payload.push(("reason", reason));
129        }
130        self.put_admin_user_action(user_id, "silence", &payload, "silence user request")
131    }
132
133    /// Unsilence a user by ID.
134    pub fn unsilence_user(&self, user_id: i64) -> Result<()> {
135        self.put_admin_user_action(user_id, "unsilence", &[], "unsilence user request")
136    }
137
138    /// Grant admin to a user.
139    pub fn grant_admin(&self, user_id: i64) -> Result<()> {
140        self.put_admin_user_action(user_id, "grant_admin", &[], "grant admin request")
141    }
142
143    /// Revoke admin from a user.
144    pub fn revoke_admin(&self, user_id: i64) -> Result<()> {
145        self.put_admin_user_action(user_id, "revoke_admin", &[], "revoke admin request")
146    }
147
148    /// Grant moderator to a user.
149    pub fn grant_moderation(&self, user_id: i64) -> Result<()> {
150        self.put_admin_user_action(
151            user_id,
152            "grant_moderation",
153            &[],
154            "grant moderation request",
155        )
156    }
157
158    /// Revoke moderator from a user.
159    pub fn revoke_moderation(&self, user_id: i64) -> Result<()> {
160        self.put_admin_user_action(
161            user_id,
162            "revoke_moderation",
163            &[],
164            "revoke moderation request",
165        )
166    }
167
168    /// Create a user. `password` is optional — omit to require the new user
169    /// to reset it via the email flow. `active=true` and `approved=true` are
170    /// passed so admin-created accounts skip the activation and approval
171    /// dances. Returns the new user id on success.
172    pub fn create_user(
173        &self,
174        email: &str,
175        username: &str,
176        password: Option<&str>,
177        name: Option<&str>,
178        approve: bool,
179    ) -> Result<i64> {
180        let mut payload: Vec<(&str, &str)> = vec![
181            ("email", email),
182            ("username", username),
183            ("active", "true"),
184        ];
185        if approve {
186            payload.push(("approved", "true"));
187        }
188        if let Some(p) = password {
189            payload.push(("password", p));
190        }
191        if let Some(n) = name {
192            if !n.is_empty() {
193                payload.push(("name", n));
194            }
195        }
196        let response = self.send_retrying(|| Ok(self.post("/u.json")?.form(&payload)))?;
197        let status = response.status();
198        let text = response.text().context("reading user create response")?;
199        if !status.is_success() {
200            return Err(http_error("user create request", status, &text));
201        }
202        let value: Value =
203            serde_json::from_str(&text).context("parsing user create response")?;
204        // Discourse wraps this variably depending on version; grab user_id from
205        // the top level first, then fall back to `user.id`.
206        let id = value
207            .get("user_id")
208            .and_then(|v| v.as_i64())
209            .or_else(|| {
210                value
211                    .get("user")
212                    .and_then(|u| u.get("id"))
213                    .and_then(|v| v.as_i64())
214            })
215            .ok_or_else(|| anyhow!("user create response missing user id: {}", text))?;
216        Ok(id)
217    }
218
219    /// Trigger the "forgot password" email flow for a user. Accepts username
220    /// or email as `login`. Discourse returns a generic success message
221    /// regardless of whether the user exists (to prevent enumeration).
222    pub fn trigger_password_reset(&self, login: &str) -> Result<()> {
223        let payload = [("login", login)];
224        let response = self
225            .send_retrying(|| Ok(self.post("/session/forgot_password.json")?.form(&payload)))?;
226        let status = response.status();
227        if !status.is_success() {
228            let text = response
229                .text()
230                .unwrap_or_else(|_| "<failed to read response body>".to_string());
231            return Err(http_error("password reset request", status, &text));
232        }
233        Ok(())
234    }
235
236    /// Admin-set a user's primary email address.
237    pub fn set_user_email(&self, username: &str, email: &str) -> Result<()> {
238        let path = format!("/u/{}/preferences/email.json", username);
239        let payload = [("email", email)];
240        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
241        let status = response.status();
242        if !status.is_success() {
243            let text = response
244                .text()
245                .unwrap_or_else(|_| "<failed to read response body>".to_string());
246            return Err(http_error("email set request", status, &text));
247        }
248        Ok(())
249    }
250
251    fn put_admin_user_action(
252        &self,
253        user_id: i64,
254        action: &str,
255        payload: &[(&str, &str)],
256        action_label: &str,
257    ) -> Result<()> {
258        let path = format!("/admin/users/{}/{}.json", user_id, action);
259        let response = self.send_retrying(|| {
260            let rb = self.put(&path)?;
261            Ok(if payload.is_empty() {
262                rb
263            } else {
264                rb.form(payload)
265            })
266        })?;
267        let status = response.status();
268        if !status.is_success() {
269            let text = response
270                .text()
271                .unwrap_or_else(|_| "<failed to read response body>".to_string());
272            return Err(http_error(action_label, status, &text));
273        }
274        Ok(())
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::{UserDetail, UserSummary};
281
282    /// Regression test for the bug captured in
283    /// `spec/user-list-negative-ids.md`: Discourse uses negative IDs for
284    /// its built-in `system` (-1) and `discobot` (-2) accounts, which
285    /// appear in the `active` listing. Parsing a page that contains them
286    /// must succeed, not fail with `invalid value: integer -2`.
287    #[test]
288    fn user_summary_accepts_negative_system_ids() {
289        let json = r#"[
290            {"id": -1, "username": "system", "name": "system",
291             "email": "no_email"},
292            {"id": -2, "username": "discobot", "name": "discobot",
293             "email": "no_email"},
294            {"id": 42, "username": "alice", "name": "Alice",
295             "email": "alice@example.com"}
296        ]"#;
297        let users: Vec<UserSummary> =
298            serde_json::from_str(json).expect("negative ids must parse");
299        assert_eq!(users.len(), 3);
300        assert_eq!(users[0].id, -1);
301        assert_eq!(users[1].id, -2);
302        assert_eq!(users[2].id, 42);
303        assert_eq!(users[0].username, "system");
304    }
305
306    #[test]
307    fn user_detail_accepts_negative_system_ids() {
308        let json = r#"{"id": -1, "username": "system"}"#;
309        let detail: UserDetail = serde_json::from_str(json).expect("must parse");
310        assert_eq!(detail.id, -1);
311        assert_eq!(detail.username, "system");
312    }
313}