1use super::models::{AboutResponse, SiteResponse};
2use super::rate_limit::{
3 RETRY_BUFFER, parse_rate_limit_wait, summarize_rate_limit_body,
4};
5use crate::config::DiscourseConfig;
6use crate::utils::normalize_baseurl;
7use anyhow::{Context, Result, anyhow};
8use reqwest::StatusCode;
9use reqwest::blocking::{Client, RequestBuilder, Response};
10use reqwest::header::{HeaderMap, HeaderValue};
11
12const MAX_RATE_LIMIT_RETRIES: u32 = 5;
13
14#[derive(Debug, Clone)]
15pub struct VersionInfo {
16 pub version: Option<String>,
17 pub commit: Option<String>,
18}
19
20#[derive(Clone)]
22pub struct DiscourseClient {
23 baseurl: String,
24 client: Client,
25}
26
27impl DiscourseClient {
28 pub fn new(config: &DiscourseConfig) -> Result<Self> {
30 let baseurl = normalize_baseurl(&config.baseurl);
31 if baseurl.is_empty() {
32 return Err(anyhow!(
33 "missing baseurl for discourse {}; please set baseurl or check your config",
34 config.name
35 ));
36 }
37
38 let mut headers = HeaderMap::new();
39 if let (Some(apikey), Some(api_username)) =
40 (config.apikey.as_ref(), config.api_username.as_ref())
41 {
42 headers.insert(
43 "Api-Key",
44 HeaderValue::from_str(apikey).context("invalid api key")?,
45 );
46 headers.insert(
47 "Api-Username",
48 HeaderValue::from_str(api_username).context("invalid api username")?,
49 );
50 }
51
52 let client = Client::builder()
53 .default_headers(headers)
54 .build()
55 .context("building http client")?;
56
57 Ok(Self { baseurl, client })
58 }
59
60 pub fn baseurl(&self) -> &str {
62 &self.baseurl
63 }
64
65 pub(crate) fn get(&self, path: &str) -> Result<Response> {
66 let url = format!("{}{}", self.baseurl, path);
67 self.send_retrying(|| Ok(self.client.get(&url)))
72 }
73
74 pub(crate) fn post(&self, path: &str) -> Result<reqwest::blocking::RequestBuilder> {
75 let url = format!("{}{}", self.baseurl, path);
76 Ok(self.client.post(url))
77 }
78
79 pub(crate) fn put(&self, path: &str) -> Result<reqwest::blocking::RequestBuilder> {
80 let url = format!("{}{}", self.baseurl, path);
81 Ok(self.client.put(url))
82 }
83
84 pub(crate) fn delete(&self, path: &str) -> Result<reqwest::blocking::Response> {
85 let url = format!("{}{}", self.baseurl, path);
86 self.client.delete(url).send().context("sending delete request")
87 }
88
89 pub(crate) fn delete_builder(&self, path: &str) -> Result<RequestBuilder> {
90 let url = format!("{}{}", self.baseurl, path);
91 Ok(self.client.delete(url))
92 }
93
94 pub(crate) fn send_retrying<F>(&self, mut build: F) -> Result<Response>
100 where
101 F: FnMut() -> Result<RequestBuilder>,
102 {
103 let mut attempt: u32 = 0;
104 loop {
105 let rb = build()?;
106 let response = rb.send().context("sending request")?;
107 if response.status() != StatusCode::TOO_MANY_REQUESTS {
108 return Ok(response);
109 }
110 let headers = response.headers().clone();
111 let body = response
112 .text()
113 .unwrap_or_else(|_| "<failed to read 429 body>".to_string());
114 if attempt >= MAX_RATE_LIMIT_RETRIES {
115 return Err(anyhow!(
116 "rate-limited after {} retries: {}",
117 MAX_RATE_LIMIT_RETRIES,
118 summarize_rate_limit_body(&body)
119 ));
120 }
121 attempt += 1;
122 let wait = parse_rate_limit_wait(&headers, &body) + RETRY_BUFFER;
123 eprintln!(
124 "Rate limited, waiting {}s (retry {}/{})",
125 wait.as_secs(),
126 attempt,
127 MAX_RATE_LIMIT_RETRIES
128 );
129 std::thread::sleep(wait);
130 }
131 }
132
133 pub fn fetch_site_title(&self) -> Result<String> {
135 let site_json_error = match self.get("/site.json") {
136 Ok(response) => {
137 let status = response.status();
138 let text = response.text().context("reading site.json response body")?;
139 if status.is_success() {
140 let body: SiteResponse =
141 serde_json::from_str(&text).context("parsing site.json")?;
142 return Ok(body.site.title);
143 }
144 anyhow!("site.json request failed with {}", status)
145 }
146 Err(err) => err,
147 };
148
149 let response = self.get("/")?;
150 let status = response.status();
151 let html = response.text().context("reading site HTML")?;
152 if !status.is_success() {
153 return Err(anyhow!(
154 "site title lookup failed (site.json error: {}; HTML request failed with {})",
155 site_json_error,
156 status
157 ));
158 }
159 if let Some(title) = extract_html_title(&html) {
160 return Ok(title);
161 }
162 Err(anyhow!(
163 "site title lookup failed (site.json error: {}; HTML missing <title>)",
164 site_json_error
165 ))
166 }
167
168 pub fn fetch_version_info(&self) -> Result<VersionInfo> {
170 let mut version = None;
171 let mut commit = None;
172 let mut last_err = None;
173
174 match self.get("/about.json") {
175 Ok(response) => {
176 let status = response.status();
177 match response.json::<AboutResponse>() {
178 Ok(body) => {
179 if status.is_success() {
180 version = body.about.version.or(body.about.installed_version);
181 } else {
182 last_err = Some(anyhow!("about.json request failed with {}", status));
183 }
184 }
185 Err(err) => {
186 last_err = Some(anyhow!("reading about.json: {}", err));
187 }
188 }
189 }
190 Err(err) => {
191 last_err = Some(err);
192 }
193 }
194
195 match self.get("/") {
196 Ok(response) => {
197 let status = response.status();
198 let html = response.text().context("reading site HTML")?;
199 if !status.is_success() {
200 last_err = Some(anyhow!("site HTML request failed with {}", status));
201 } else if let Some(content) = extract_meta_content(&html, "generator") {
202 let (html_version, html_commit) = parse_generator_content(&content);
203 if version.is_none() {
204 version = html_version;
205 }
206 if commit.is_none() {
207 commit = html_commit;
208 }
209 }
210 }
211 Err(err) => {
212 last_err = Some(err);
213 }
214 }
215
216 if version.is_none() && commit.is_none() {
217 return Err(last_err.unwrap_or_else(|| anyhow!("version fetch failed")));
218 }
219
220 Ok(VersionInfo { version, commit })
221 }
222
223 pub fn fetch_version(&self) -> Result<Option<String>> {
225 Ok(self.fetch_version_info()?.version)
226 }
227}
228
229fn extract_html_title(html: &str) -> Option<String> {
230 let haystack = html.as_bytes();
231 let mut lower = Vec::with_capacity(haystack.len());
232 for &byte in haystack {
233 lower.push(byte.to_ascii_lowercase());
234 }
235 let open_tag = b"<title>";
236 let close_tag = b"</title>";
237 let start = find_subslice(&lower, open_tag)? + open_tag.len();
238 let end = find_subslice(&lower[start..], close_tag)? + start;
239 let title = String::from_utf8_lossy(&haystack[start..end])
240 .trim()
241 .to_string();
242 if title.is_empty() { None } else { Some(title) }
243}
244
245fn extract_meta_content(html: &str, name: &str) -> Option<String> {
246 let lower = html.to_ascii_lowercase();
247 let name_attr = format!("name=\"{}\"", name.to_ascii_lowercase());
248 let name_attr_single = format!("name='{}'", name.to_ascii_lowercase());
249
250 let mut start = 0;
251 while let Some(pos) = lower[start..].find("<meta") {
252 let tag_start = start + pos;
253 let rest = &lower[tag_start..];
254 let tag_end = rest.find('>')? + tag_start;
255 let tag_lower = &lower[tag_start..tag_end];
256 if tag_lower.contains(&name_attr) || tag_lower.contains(&name_attr_single) {
257 let tag_original = &html[tag_start..tag_end];
258 if let Some(value) = extract_attr_value(tag_original, "content") {
259 return Some(value);
260 }
261 }
262 start = tag_end + 1;
263 }
264 None
265}
266
267fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
268 let lower = tag.to_ascii_lowercase();
269 let attr_eq = format!("{}=", attr.to_ascii_lowercase());
270 let pos = lower.find(&attr_eq)? + attr_eq.len();
271 let rest = &tag[pos..];
272 let mut chars = rest.chars();
273 let quote = chars.next()?;
274 if quote != '"' && quote != '\'' {
275 return None;
276 }
277 let value: String = chars.take_while(|c| *c != quote).collect();
278 if value.is_empty() { None } else { Some(value) }
279}
280
281fn parse_generator_content(content: &str) -> (Option<String>, Option<String>) {
282 let mut version = None;
283 let mut commit = None;
284
285 if let Some(rest) = content.strip_prefix("Discourse ") {
286 let ver = rest.split(" - ").next().map(|s| s.trim()).unwrap_or("");
287 if !ver.is_empty() {
288 version = Some(ver.to_string());
289 }
290 }
291
292 if let Some(idx) = content.find("version ") {
293 let tail = &content[idx + "version ".len()..];
294 let hash = tail.split_whitespace().next().unwrap_or("");
295 if !hash.is_empty() {
296 commit = Some(hash.to_string());
297 }
298 }
299
300 (version, commit)
301}
302
303fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
304 if needle.is_empty() || haystack.len() < needle.len() {
305 return None;
306 }
307 haystack
308 .windows(needle.len())
309 .position(|window| window == needle)
310}