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;
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    ) -> anyhow::Result<OrdinaryApiClient<'a>> {
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() -> anyhow::Result<Vec<AccountMeta>> {
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) -> anyhow::Result<AccountMeta> {
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    ) -> anyhow::Result<(TOTP, String)> {
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) -> anyhow::Result<()> {
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>) -> anyhow::Result<Vec<u8>> {
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    ) -> anyhow::Result<()> {
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    ) -> anyhow::Result<String> {
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(&self, proj_path: Option<&str>) -> anyhow::Result<String> {
216        ops::account::accounts_list(self, proj_path).await
217    }
218
219    /// deploy app to a API server
220    #[instrument(skip(self), err)]
221    pub async fn deploy(&self, proj_path: &str) -> anyhow::Result<u16> {
222        ops::app::deploy(self, proj_path).await
223    }
224
225    /// patch app config on API server
226    #[instrument(skip(self), err)]
227    pub async fn patch(&self, proj_path: &str) -> anyhow::Result<u16> {
228        ops::app::patch(self, proj_path).await
229    }
230
231    /// kill app on API server
232    #[instrument(skip(self), err)]
233    pub async fn kill(&self, proj_path: &str) -> anyhow::Result<()> {
234        ops::app::kill(self, proj_path).await
235    }
236
237    /// query an app's logs
238    #[instrument(skip(self), err)]
239    pub async fn app_logs(
240        &self,
241        proj_path: &str,
242        query: &str,
243        format: &str,
244        limit: &Option<usize>,
245    ) -> anyhow::Result<Value> {
246        ops::app::logs(self, proj_path, query, format, limit).await
247    }
248
249    /// list accounts for an app
250    #[instrument(skip(self), err)]
251    pub async fn app_accounts_list(&self, proj_path: &str) -> anyhow::Result<String> {
252        ops::app::accounts_list(self, proj_path).await
253    }
254
255    /// list app items by their model index
256    #[instrument(skip(self), err)]
257    pub async fn items_list(&self, proj_path: &str, model_name: &str) -> anyhow::Result<String> {
258        ops::app::items_list(self, proj_path, model_name).await
259    }
260
261    /// write a single asset
262    #[instrument(skip(self), err)]
263    pub async fn write(&self, proj_path: &str, asset_path: &str) -> anyhow::Result<()> {
264        ops::assets::write(self, proj_path, asset_path).await
265    }
266
267    /// write all assets in assets `dir_path`
268    #[instrument(skip(self), err)]
269    pub async fn write_all(&self, proj_path: &str) -> anyhow::Result<()> {
270        ops::assets::write_all(self, proj_path).await
271    }
272
273    /// upload a single template
274    #[instrument(skip(self), err)]
275    pub async fn upload(&self, proj_path: &str, template_name: &str) -> anyhow::Result<()> {
276        ops::templates::upload(self, proj_path, template_name).await
277    }
278
279    /// upload all templates
280    #[instrument(skip(self), err)]
281    pub async fn upload_all(&self, proj_path: &str) -> anyhow::Result<()> {
282        ops::templates::upload_all(self, proj_path).await
283    }
284
285    /// install single action
286    #[instrument(skip(self), err)]
287    pub async fn install(&self, proj_path: &str, action_name: &str) -> anyhow::Result<()> {
288        ops::actions::install(self, proj_path, action_name).await
289    }
290
291    /// install all actions
292    #[instrument(skip(self), err)]
293    pub async fn install_all(&self, proj_path: &str) -> anyhow::Result<()> {
294        ops::actions::install_all(self, proj_path).await
295    }
296
297    /// update content
298    #[instrument(skip(self), err)]
299    pub async fn update(&self, proj_path: &str) -> anyhow::Result<()> {
300        ops::content::update(self, proj_path).await
301    }
302
303    fn sign_token(
304        client_dir: &Path,
305        token: &mut BytesMut,
306        signing_key: Option<SigningKey>,
307        duration_s: Option<u32>,
308    ) -> anyhow::Result<()> {
309        let client_exp = u32::try_from(
310            match SystemTime::now()
311                .checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
312            {
313                Some(v) => v,
314                None => bail!("failed to add"),
315            }
316            .duration_since(SystemTime::UNIX_EPOCH)?
317            .as_secs(),
318        )?;
319
320        token.put_u32(client_exp);
321
322        let mut signing_key = if let Some(sk) = signing_key {
323            sk
324        } else {
325            let signing_key_bytes: [u8; 32] =
326                match std::fs::read(client_dir.join("signing_key"))?.try_into() {
327                    Ok(v) => v,
328                    Err(_) => bail!("failed to convert"),
329                };
330
331            let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
332            signing_key
333        };
334
335        let signature = signing_key.sign(&token[..]);
336
337        token.put(&signature.to_bytes()[..]);
338
339        Ok(())
340    }
341}
342
343pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
344    if dir.is_dir() {
345        for entry in std::fs::read_dir(dir)? {
346            let entry = entry?;
347            let path = entry.path();
348            if path.is_dir() {
349                traverse(&path, cb)?;
350            } else {
351                cb(&entry);
352            }
353        }
354    }
355    Ok(())
356}