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#[derive(Debug, Deserialize, Serialize, Clone)]
9pub struct UserSummary {
10    pub id: u64,
11    pub username: String,
12    #[serde(default)]
13    pub name: Option<String>,
14    #[serde(default)]
15    pub email: Option<String>,
16    #[serde(default)]
17    pub trust_level: Option<u64>,
18    #[serde(default)]
19    pub admin: Option<bool>,
20    #[serde(default)]
21    pub moderator: Option<bool>,
22    #[serde(default)]
23    pub suspended: Option<bool>,
24    #[serde(default)]
25    pub silenced: Option<bool>,
26    #[serde(default)]
27    pub last_seen_at: Option<String>,
28    #[serde(default)]
29    pub created_at: Option<String>,
30}
31
32/// Distilled /users/<username>.json payload.
33#[derive(Debug, Deserialize, Serialize, Clone)]
34pub struct UserDetail {
35    pub id: u64,
36    pub username: String,
37    #[serde(default)]
38    pub name: Option<String>,
39    #[serde(default)]
40    pub email: Option<String>,
41    #[serde(default)]
42    pub trust_level: Option<u64>,
43    #[serde(default)]
44    pub admin: Option<bool>,
45    #[serde(default)]
46    pub moderator: Option<bool>,
47    #[serde(default)]
48    pub suspended_till: Option<String>,
49    #[serde(default)]
50    pub silenced_till: Option<String>,
51    #[serde(default)]
52    pub last_seen_at: Option<String>,
53    #[serde(default)]
54    pub created_at: Option<String>,
55    #[serde(default)]
56    pub post_count: Option<u64>,
57    #[serde(default)]
58    pub groups: Vec<Value>,
59}
60
61impl DiscourseClient {
62    /// List users via the admin users endpoint.
63    ///
64    /// `listing` is one of: `active` (default), `new`, `staff`, `suspended`,
65    /// `silenced`, `staged`. Discourse paginates 100 per page.
66    pub fn admin_list_users(&self, listing: &str, page: u32) -> Result<Vec<UserSummary>> {
67        let path = format!(
68            "/admin/users/list/{}.json?show_emails=true&page={}",
69            listing, page
70        );
71        let response = self.get(&path)?;
72        let status = response.status();
73        let text = response.text().context("reading user list response")?;
74        if !status.is_success() {
75            return Err(http_error("admin user list request", status, &text));
76        }
77        let users: Vec<UserSummary> =
78            serde_json::from_str(&text).context("parsing user list response")?;
79        Ok(users)
80    }
81
82    /// Look up a user by username (public endpoint).
83    pub fn fetch_user_detail(&self, username: &str) -> Result<UserDetail> {
84        let path = format!("/u/{}.json", username);
85        let response = self.get(&path)?;
86        let status = response.status();
87        let text = response.text().context("reading user detail response")?;
88        if !status.is_success() {
89            return Err(http_error("user detail request", status, &text));
90        }
91        let value: Value =
92            serde_json::from_str(&text).context("parsing user detail response")?;
93        let user = value
94            .get("user")
95            .ok_or_else(|| anyhow!("user detail response missing `user` field"))?;
96        let detail: UserDetail =
97            serde_json::from_value(user.clone()).context("deserialising user detail")?;
98        Ok(detail)
99    }
100
101    /// Suspend a user by ID. `until` is an ISO-8601 timestamp (or any string
102    /// Discourse accepts, like "forever"); `reason` is mandatory from the UI
103    /// but Discourse accepts empty via the API.
104    pub fn suspend_user(&self, user_id: u64, until: &str, reason: &str) -> Result<()> {
105        let payload = [("suspend_until", until), ("reason", reason)];
106        self.put_admin_user_action(user_id, "suspend", &payload, "suspend user request")
107    }
108
109    /// Unsuspend a user by ID.
110    pub fn unsuspend_user(&self, user_id: u64) -> Result<()> {
111        self.put_admin_user_action(user_id, "unsuspend", &[], "unsuspend user request")
112    }
113
114    /// Silence a user by ID. Optional `silenced_till` (Discourse-accepted
115    /// timestamp string) and `reason`; both default to empty.
116    pub fn silence_user(&self, user_id: u64, until: &str, reason: &str) -> Result<()> {
117        let mut payload: Vec<(&str, &str)> = Vec::new();
118        if !until.is_empty() {
119            payload.push(("silenced_till", until));
120        }
121        if !reason.is_empty() {
122            payload.push(("reason", reason));
123        }
124        self.put_admin_user_action(user_id, "silence", &payload, "silence user request")
125    }
126
127    /// Unsilence a user by ID.
128    pub fn unsilence_user(&self, user_id: u64) -> Result<()> {
129        self.put_admin_user_action(user_id, "unsilence", &[], "unsilence user request")
130    }
131
132    /// Grant admin to a user.
133    pub fn grant_admin(&self, user_id: u64) -> Result<()> {
134        self.put_admin_user_action(user_id, "grant_admin", &[], "grant admin request")
135    }
136
137    /// Revoke admin from a user.
138    pub fn revoke_admin(&self, user_id: u64) -> Result<()> {
139        self.put_admin_user_action(user_id, "revoke_admin", &[], "revoke admin request")
140    }
141
142    /// Grant moderator to a user.
143    pub fn grant_moderation(&self, user_id: u64) -> Result<()> {
144        self.put_admin_user_action(
145            user_id,
146            "grant_moderation",
147            &[],
148            "grant moderation request",
149        )
150    }
151
152    /// Revoke moderator from a user.
153    pub fn revoke_moderation(&self, user_id: u64) -> Result<()> {
154        self.put_admin_user_action(
155            user_id,
156            "revoke_moderation",
157            &[],
158            "revoke moderation request",
159        )
160    }
161
162    /// Create a user. `password` is optional — omit to require the new user
163    /// to reset it via the email flow. `active=true` and `approved=true` are
164    /// passed so admin-created accounts skip the activation and approval
165    /// dances. Returns the new user id on success.
166    pub fn create_user(
167        &self,
168        email: &str,
169        username: &str,
170        password: Option<&str>,
171        name: Option<&str>,
172        approve: bool,
173    ) -> Result<u64> {
174        let mut payload: Vec<(&str, &str)> = vec![
175            ("email", email),
176            ("username", username),
177            ("active", "true"),
178        ];
179        if approve {
180            payload.push(("approved", "true"));
181        }
182        if let Some(p) = password {
183            payload.push(("password", p));
184        }
185        if let Some(n) = name {
186            if !n.is_empty() {
187                payload.push(("name", n));
188            }
189        }
190        let response = self.send_retrying(|| Ok(self.post("/u.json")?.form(&payload)))?;
191        let status = response.status();
192        let text = response.text().context("reading user create response")?;
193        if !status.is_success() {
194            return Err(http_error("user create request", status, &text));
195        }
196        let value: Value =
197            serde_json::from_str(&text).context("parsing user create response")?;
198        // Discourse wraps this variably depending on version; grab user_id from
199        // the top level first, then fall back to `user.id`.
200        let id = value
201            .get("user_id")
202            .and_then(|v| v.as_u64())
203            .or_else(|| {
204                value
205                    .get("user")
206                    .and_then(|u| u.get("id"))
207                    .and_then(|v| v.as_u64())
208            })
209            .ok_or_else(|| anyhow!("user create response missing user id: {}", text))?;
210        Ok(id)
211    }
212
213    /// Trigger the "forgot password" email flow for a user. Accepts username
214    /// or email as `login`. Discourse returns a generic success message
215    /// regardless of whether the user exists (to prevent enumeration).
216    pub fn trigger_password_reset(&self, login: &str) -> Result<()> {
217        let payload = [("login", login)];
218        let response = self
219            .send_retrying(|| Ok(self.post("/session/forgot_password.json")?.form(&payload)))?;
220        let status = response.status();
221        if !status.is_success() {
222            let text = response
223                .text()
224                .unwrap_or_else(|_| "<failed to read response body>".to_string());
225            return Err(http_error("password reset request", status, &text));
226        }
227        Ok(())
228    }
229
230    /// Admin-set a user's primary email address.
231    pub fn set_user_email(&self, username: &str, email: &str) -> Result<()> {
232        let path = format!("/u/{}/preferences/email.json", username);
233        let payload = [("email", email)];
234        let response = self.send_retrying(|| Ok(self.post(&path)?.form(&payload)))?;
235        let status = response.status();
236        if !status.is_success() {
237            let text = response
238                .text()
239                .unwrap_or_else(|_| "<failed to read response body>".to_string());
240            return Err(http_error("email set request", status, &text));
241        }
242        Ok(())
243    }
244
245    fn put_admin_user_action(
246        &self,
247        user_id: u64,
248        action: &str,
249        payload: &[(&str, &str)],
250        action_label: &str,
251    ) -> Result<()> {
252        let path = format!("/admin/users/{}/{}.json", user_id, action);
253        let response = self.send_retrying(|| {
254            let rb = self.put(&path)?;
255            Ok(if payload.is_empty() {
256                rb
257            } else {
258                rb.form(payload)
259            })
260        })?;
261        let status = response.status();
262        if !status.is_success() {
263            let text = response
264                .text()
265                .unwrap_or_else(|_| "<failed to read response body>".to_string());
266            return Err(http_error(action_label, status, &text));
267        }
268        Ok(())
269    }
270}