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