use std::{fs, path::PathBuf};
use common::prelude::SecretKey;
use serde::{Deserialize, Serialize};
pub const APP_NAME: &str = "jax";
pub const CONFIG_FILE_NAME: &str = "config.toml";
pub const DB_FILE_NAME: &str = "db.sqlite";
pub const KEY_FILE_NAME: &str = "key.pem";
pub const BLOBS_DIR_NAME: &str = "blobs";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_api_port")]
pub api_port: u16,
#[serde(default = "default_gateway_port")]
pub gateway_port: u16,
#[serde(default)]
pub peer_port: Option<u16>,
#[serde(default)]
pub blob_store: BlobStoreConfig,
#[serde(default = "default_max_import_size")]
pub max_import_size: u64,
}
fn default_api_port() -> u16 {
5001
}
fn default_gateway_port() -> u16 {
8080
}
fn default_max_import_size() -> u64 {
object_store::DEFAULT_MAX_IMPORT_SIZE
}
impl Default for AppConfig {
fn default() -> Self {
Self {
api_port: default_api_port(),
gateway_port: default_gateway_port(),
peer_port: None,
blob_store: BlobStoreConfig::default(),
max_import_size: default_max_import_size(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BlobStoreConfig {
#[default]
Legacy,
Filesystem {
path: PathBuf,
#[serde(default)]
db_path: Option<PathBuf>,
},
S3 {
url: String,
},
}
#[derive(Debug, Clone)]
pub struct S3Config {
pub endpoint: String,
pub access_key: String,
pub secret_key: String,
pub bucket: String,
}
impl BlobStoreConfig {
pub fn parse_s3_url(url: &str) -> Result<S3Config, StateError> {
let url = url
.strip_prefix("s3://")
.ok_or_else(|| StateError::InvalidS3Url("URL must start with s3://".to_string()))?;
let (creds, rest) = url
.split_once('@')
.ok_or_else(|| StateError::InvalidS3Url("Missing @ separator".to_string()))?;
let (access_key, secret_key) = creds
.split_once(':')
.ok_or_else(|| StateError::InvalidS3Url("Missing : in credentials".to_string()))?;
let (endpoint, bucket) = rest
.split_once('/')
.ok_or_else(|| StateError::InvalidS3Url("Missing / before bucket".to_string()))?;
if bucket.is_empty() {
return Err(StateError::InvalidS3Url("Bucket name is empty".to_string()));
}
let protocol = if endpoint.contains("localhost") || endpoint.contains("127.0.0.1") {
"http"
} else {
"https"
};
Ok(S3Config {
endpoint: format!("{}://{}", protocol, endpoint),
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
bucket: bucket.to_string(),
})
}
}
#[derive(Debug, Clone)]
pub struct AppState {
pub jax_dir: PathBuf,
pub db_path: PathBuf,
pub key_path: PathBuf,
pub blobs_path: PathBuf,
pub config_path: PathBuf,
pub config: AppConfig,
}
impl AppState {
pub fn jax_dir(custom_path: Option<PathBuf>) -> Result<PathBuf, StateError> {
if let Some(path) = custom_path {
return Ok(path);
}
let home = dirs::home_dir().ok_or(StateError::NoHomeDirectory)?;
Ok(home.join(format!(".{}", APP_NAME)))
}
#[allow(dead_code)]
pub fn exists(custom_path: Option<PathBuf>) -> Result<bool, StateError> {
let jax_dir = Self::jax_dir(custom_path)?;
Ok(jax_dir.exists())
}
pub fn init(
custom_path: Option<PathBuf>,
config: Option<AppConfig>,
) -> Result<Self, StateError> {
let jax_dir = Self::jax_dir(custom_path)?;
if jax_dir.exists() {
return Err(StateError::AlreadyInitialized);
}
fs::create_dir_all(&jax_dir)?;
let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
fs::create_dir_all(&blobs_path)?;
let key = SecretKey::generate();
let key_path = jax_dir.join(KEY_FILE_NAME);
fs::write(&key_path, key.to_pem())?;
let config = config.unwrap_or_default();
let config_path = jax_dir.join(CONFIG_FILE_NAME);
let config_toml = toml::to_string_pretty(&config)?;
fs::write(&config_path, config_toml)?;
let db_path = jax_dir.join(DB_FILE_NAME);
fs::write(&db_path, "")?;
Ok(Self {
jax_dir,
db_path,
key_path,
blobs_path,
config_path,
config,
})
}
pub fn load(custom_path: Option<PathBuf>) -> Result<Self, StateError> {
let jax_dir = Self::jax_dir(custom_path)?;
if !jax_dir.exists() {
return Err(StateError::NotInitialized);
}
let db_path = jax_dir.join(DB_FILE_NAME);
let key_path = jax_dir.join(KEY_FILE_NAME);
let blobs_path = jax_dir.join(BLOBS_DIR_NAME);
let config_path = jax_dir.join(CONFIG_FILE_NAME);
if !db_path.exists() {
return Err(StateError::MissingFile("db.sqlite".to_string()));
}
if !key_path.exists() {
return Err(StateError::MissingFile("key.pem".to_string()));
}
if !blobs_path.exists() {
return Err(StateError::MissingFile("blobs/".to_string()));
}
if !config_path.exists() {
return Err(StateError::MissingFile("config.toml".to_string()));
}
let config_toml = fs::read_to_string(&config_path)?;
let config: AppConfig = toml::from_str(&config_toml)?;
Ok(Self {
jax_dir,
db_path,
key_path,
blobs_path,
config_path,
config,
})
}
pub fn load_key(&self) -> Result<SecretKey, StateError> {
let pem = fs::read_to_string(&self.key_path)?;
let key = SecretKey::from_pem(&pem).map_err(|e| StateError::InvalidKey(e.to_string()))?;
Ok(key)
}
}
#[derive(Debug, thiserror::Error)]
pub enum StateError {
#[error("jax directory not initialized. Run 'cli init' first")]
NotInitialized,
#[error("jax directory already initialized")]
AlreadyInitialized,
#[error("no home directory found")]
NoHomeDirectory,
#[error("missing required file: {0}")]
MissingFile(String),
#[error("invalid key: {0}")]
InvalidKey(String),
#[error("invalid S3 URL: {0}")]
InvalidS3Url(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML serialization error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("TOML deserialization error: {0}")]
TomlDe(#[from] toml::de::Error),
}