Skip to main content

dsc/api/
client.rs

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/// HTTP client for the Discourse API.
15#[derive(Clone)]
16pub struct DiscourseClient {
17    baseurl: String,
18    client: Client,
19}
20
21impl DiscourseClient {
22    /// Create a new Discourse API client.
23    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    /// Return the configured base URL.
55    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    /// Fetch the Discourse site title.
80    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    /// Fetch the current Discourse version and commit hash.
115    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    /// Fetch the current Discourse version (best-effort).
170    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}