Skip to main content

ordinary_api/client/
mod.rs

1mod ops;
2
3use bytes::{BufMut, BytesMut};
4use ordinary_auth::token::{extract_hmac_no_check, get_exp};
5use serde_json::Value;
6use std::env::home_dir;
7use std::error::Error;
8use std::fs::DirEntry;
9use std::path::Path;
10use std::path::PathBuf;
11use std::time::{Duration, SystemTime};
12use tracing::instrument;
13
14use ed25519_dalek::{SigningKey, ed25519::signature::SignerMut};
15use reqwest::Client;
16use sha2::{Digest, Sha256};
17use totp_rs::TOTP;
18
19/// client for interaction with Ordinary API Server
20pub struct OrdinaryApiClient<'a> {
21    pub(crate) addr: &'a str,
22    pub(crate) account: &'a str,
23    pub(crate) api_domain: Option<&'a str>,
24    pub(crate) client: Client,
25}
26
27fn compress_zstd(val: &[u8]) -> std::io::Result<Vec<u8>> {
28    zstd::stream::encode_all(std::io::Cursor::new(val), 17)
29}
30
31fn strip_http(addr: &str) -> &str {
32    if let Some(stripped) = addr.strip_prefix("https://") {
33        return stripped;
34    }
35    if let Some(stripped) = addr.strip_prefix("http://") {
36        return stripped;
37    }
38
39    addr
40}
41
42fn get_client_dir(domain: &str, account: &str) -> PathBuf {
43    home_dir()
44        .expect("failed to get home dir")
45        .join(".ordinary")
46        .join("clients")
47        .join(domain)
48        .join(account)
49}
50
51pub struct AccountMeta {
52    pub host: String,
53    pub name: String,
54    pub project: String,
55    pub permissions: Vec<u8>,
56    pub refresh_exp: u32,
57}
58
59impl<'a> OrdinaryApiClient<'a> {
60    pub fn new(
61        addr: &'a str,
62        account: &'a str,
63        api_domain: Option<&'a str>,
64        danger_accept_invalid_certs: bool,
65        user_agent: &str,
66    ) -> Result<OrdinaryApiClient<'a>, Box<dyn Error>> {
67        tracing::debug!("initializing Ordinary API client");
68
69        let mut client_builder = Client::builder().use_rustls_tls().zstd(true);
70        client_builder = client_builder.user_agent(user_agent);
71
72        if danger_accept_invalid_certs {
73            client_builder = client_builder.danger_accept_invalid_certs(true);
74        }
75
76        let client = client_builder.build()?;
77
78        Ok(OrdinaryApiClient {
79            addr,
80            account,
81            api_domain,
82            client,
83        })
84    }
85
86    pub(crate) fn get_password_hash_and_domain(&self, password: &str) -> (Vec<u8>, &'a str) {
87        let (mut input, domain) = match self.api_domain {
88            Some(domain) => (domain.as_bytes().to_vec(), domain),
89            None => (
90                strip_http(self.addr).as_bytes().to_vec(),
91                strip_http(self.addr),
92            ),
93        };
94
95        input.extend_from_slice(self.account.as_bytes());
96        input.extend_from_slice(password.as_bytes());
97
98        let mut hasher = Sha256::new();
99        hasher.update(&input);
100        let password = hasher.finalize().to_vec();
101
102        (password, domain)
103    }
104
105    #[allow(clippy::missing_panics_doc)]
106    pub fn list_accounts() -> Result<Vec<AccountMeta>, Box<dyn Error>> {
107        let clients = home_dir()
108            .expect("failed to get home dir")
109            .join(".ordinary")
110            .join("clients");
111
112        let mut out = vec![];
113
114        for entry in std::fs::read_dir(&clients)? {
115            let path = entry?.path();
116
117            if path.is_dir()
118                && let Some(host) = &path.strip_prefix(&clients)?.to_str()
119            {
120                for entry in std::fs::read_dir(&path)? {
121                    let path = entry?.path();
122
123                    if path.is_dir()
124                        && let Some(account) = &path.strip_prefix(clients.join(host))?.to_str()
125                    {
126                        out.push(Self::get_account(host, account)?);
127                    }
128                }
129            }
130        }
131
132        Ok(out)
133    }
134
135    pub fn get_account(host: &str, account: &str) -> Result<AccountMeta, Box<dyn Error>> {
136        let path = get_client_dir(host, account);
137
138        let refresh_token = std::fs::read(path.join("refresh_token"))?;
139        let access_token = std::fs::read(path.join("access_token"))?;
140
141        let claims = extract_hmac_no_check(&access_token)?;
142
143        let claims_vec = match flexbuffers::Reader::get_root(
144            &claims[..claims.len().checked_sub(4 + 64).unwrap_or(claims.len())],
145        ) {
146            Ok(v) => v.as_vector(),
147            Err(_) => flexbuffers::Reader::get_root(claims)?.as_vector(),
148        };
149
150        let project = claims_vec.idx(1).as_str();
151        let permissions = claims_vec
152            .idx(2)
153            .as_vector()
154            .iter()
155            .map(|r| r.as_u8())
156            .collect::<Vec<u8>>();
157
158        Ok(AccountMeta {
159            host: (*host).to_owned(),
160            name: (*account).to_owned(),
161            project: project.to_owned(),
162            permissions,
163            refresh_exp: get_exp(&refresh_token)?,
164        })
165    }
166
167    /// register with API server
168    #[instrument(skip(self, password, invite_code), err)]
169    pub async fn register(
170        &self,
171        password: &str,
172        invite_code: &str,
173    ) -> Result<(TOTP, String), Box<dyn Error>> {
174        ops::account::register(self, password, &invite_code).await
175    }
176
177    /// log in to API server
178    #[instrument(skip(self, password, mfa_code), err)]
179    pub async fn login(&self, password: &str, mfa_code: &str) -> Result<(), Box<dyn Error>> {
180        ops::account::login(self, password, mfa_code).await
181    }
182
183    /// get access token for API server
184    pub async fn get_access(&self, duration_s: Option<u32>) -> Result<Vec<u8>, Box<dyn Error>> {
185        ops::account::get_access(self, duration_s).await
186    }
187
188    #[instrument(skip(self), err)]
189    pub async fn reset_password(
190        &self,
191        old_password: &str,
192        mfa_code: &str,
193        new_password: &str,
194    ) -> Result<(), Box<dyn Error>> {
195        ops::account::reset_password(self, old_password, mfa_code, new_password).await
196    }
197
198    /// invite API account
199    #[instrument(skip(self), err)]
200    pub async fn invite_api_account(
201        &self,
202        app_domain: &str,
203        account_name: &str,
204        permissions: Vec<u8>,
205    ) -> Result<String, Box<dyn Error>> {
206        ops::account::invite_account(self, app_domain, account_name, permissions).await
207    }
208
209    /// list accounts for the API server
210    #[instrument(skip(self), err)]
211    pub async fn api_accounts_list(
212        &self,
213        proj_path: Option<&str>,
214    ) -> Result<String, Box<dyn Error>> {
215        ops::account::accounts_list(self, proj_path).await
216    }
217
218    /// deploy app to a API server
219    #[instrument(skip(self), err)]
220    pub async fn deploy(&self, proj_path: &str) -> Result<u16, Box<dyn Error>> {
221        ops::app::deploy(self, proj_path).await
222    }
223
224    /// patch app config on API server
225    #[instrument(skip(self), err)]
226    pub async fn patch(&self, proj_path: &str) -> Result<u16, Box<dyn Error>> {
227        ops::app::patch(self, proj_path).await
228    }
229
230    /// kill app on API server
231    #[instrument(skip(self), err)]
232    pub async fn kill(&self, proj_path: &str) -> Result<(), Box<dyn Error>> {
233        ops::app::kill(self, proj_path).await
234    }
235
236    /// query an app's logs
237    #[instrument(skip(self), err)]
238    pub async fn app_logs(
239        &self,
240        proj_path: &str,
241        query: &str,
242        format: &str,
243        limit: &Option<usize>,
244    ) -> Result<Value, Box<dyn Error>> {
245        ops::app::logs(self, proj_path, query, format, limit).await
246    }
247
248    /// list accounts for an app
249    #[instrument(skip(self), err)]
250    pub async fn app_accounts_list(&self, proj_path: &str) -> Result<String, Box<dyn Error>> {
251        ops::app::accounts_list(self, proj_path).await
252    }
253
254    /// list app items by their model index
255    #[instrument(skip(self), err)]
256    pub async fn items_list(
257        &self,
258        proj_path: &str,
259        model_name: &str,
260    ) -> Result<String, Box<dyn Error>> {
261        ops::app::items_list(self, proj_path, model_name).await
262    }
263
264    /// write a single asset
265    #[instrument(skip(self), err)]
266    pub async fn write(&self, proj_path: &str, asset_path: &str) -> Result<(), Box<dyn Error>> {
267        ops::assets::write(self, proj_path, asset_path).await
268    }
269
270    /// write all assets in assets `dir_path`
271    #[instrument(skip(self), err)]
272    pub async fn write_all(&self, proj_path: &str) -> Result<(), Box<dyn Error>> {
273        ops::assets::write_all(self, proj_path).await
274    }
275
276    /// upload a single template
277    #[instrument(skip(self), err)]
278    pub async fn upload(&self, proj_path: &str, template_name: &str) -> Result<(), Box<dyn Error>> {
279        ops::templates::upload(self, proj_path, template_name).await
280    }
281
282    /// upload all templates
283    #[instrument(skip(self), err)]
284    pub async fn upload_all(&self, proj_path: &str) -> Result<(), Box<dyn Error>> {
285        ops::templates::upload_all(self, proj_path).await
286    }
287
288    /// install single action
289    #[instrument(skip(self), err)]
290    pub async fn install(&self, proj_path: &str, action_name: &str) -> Result<(), Box<dyn Error>> {
291        ops::actions::install(self, proj_path, action_name).await
292    }
293
294    /// install all actions
295    #[instrument(skip(self), err)]
296    pub async fn install_all(&self, proj_path: &str) -> Result<(), Box<dyn Error>> {
297        ops::actions::install_all(self, proj_path).await
298    }
299
300    /// update content
301    #[instrument(skip(self), err)]
302    pub async fn update(&self, proj_path: &str) -> Result<(), Box<dyn Error>> {
303        ops::content::update(self, proj_path).await
304    }
305
306    fn sign_token(
307        client_dir: &Path,
308        token: &mut BytesMut,
309        signing_key: Option<SigningKey>,
310        duration_s: Option<u32>,
311    ) -> Result<(), Box<dyn Error>> {
312        let client_exp = u32::try_from(
313            match SystemTime::now()
314                .checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
315            {
316                Some(v) => v,
317                None => return Err("failed to add".into()),
318            }
319            .duration_since(SystemTime::UNIX_EPOCH)?
320            .as_secs(),
321        )?;
322
323        token.put_u32(client_exp);
324
325        let mut signing_key = if let Some(sk) = signing_key {
326            sk
327        } else {
328            let signing_key_bytes: [u8; 32] =
329                match std::fs::read(client_dir.join("signing_key"))?.try_into() {
330                    Ok(v) => v,
331                    Err(_) => return Err("failed to convert".into()),
332                };
333
334            let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
335            signing_key
336        };
337
338        let signature = signing_key.sign(&token[..]);
339
340        token.put(&signature.to_bytes()[..]);
341
342        Ok(())
343    }
344}
345
346pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
347    if dir.is_dir() {
348        for entry in std::fs::read_dir(dir)? {
349            let entry = entry?;
350            let path = entry.path();
351            if path.is_dir() {
352                traverse(&path, cb)?;
353            } else {
354                cb(&entry);
355            }
356        }
357    }
358    Ok(())
359}