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