Skip to main content

ordinary_api/client/
mod.rs

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