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