414 lines
17 KiB
Rust
414 lines
17 KiB
Rust
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use clap::Parser;
|
|
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
|
|
use tokio::sync::RwLock;
|
|
use axum::http::header::HeaderValue;
|
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
use tower_http::trace::{DefaultOnResponse, TraceLayer};
|
|
use tracing::{info_span, Level};
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use rustycms::api::cache::ContentCache;
|
|
use rustycms::api::handlers::AppState;
|
|
use rustycms::referrers::{Referrer, ReferrerIndex};
|
|
use rustycms::schema::validator;
|
|
use rustycms::schema::SchemaRegistry;
|
|
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "rustycms", about = "A file-based headless CMS written in Rust")]
|
|
struct Cli {
|
|
/// Path to the directory containing type definitions (*.json5)
|
|
#[arg(long, default_value = "./types", env = "RUSTYCMS_TYPES_DIR")]
|
|
types_dir: PathBuf,
|
|
|
|
/// Path to the directory containing content files
|
|
#[arg(long, default_value = "./content", env = "RUSTYCMS_CONTENT_DIR")]
|
|
content_dir: PathBuf,
|
|
|
|
/// Port to listen on
|
|
#[arg(short = 'p', long, default_value_t = 3000)]
|
|
port: u16,
|
|
|
|
/// Host address to bind to
|
|
#[arg(long, default_value = "127.0.0.1")]
|
|
host: String,
|
|
}
|
|
|
|
/// Auto-detect locale subdirectories in the content dir.
|
|
/// Matches 2-3 letter directory names (e.g. "de", "en", "fra") that contain at
|
|
/// least one subdirectory themselves, ignoring known non-locale dirs like "assets".
|
|
fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
|
|
let entries = std::fs::read_dir(content_dir).ok()?;
|
|
let mut locales: Vec<String> = entries
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
|
|
.filter_map(|e| {
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
if name == "assets" || name.starts_with('.') {
|
|
return None;
|
|
}
|
|
let is_locale = (2..=3).contains(&name.len()) && name.chars().all(|c| c.is_ascii_lowercase());
|
|
if !is_locale {
|
|
return None;
|
|
}
|
|
let has_subdirs = std::fs::read_dir(e.path())
|
|
.ok()
|
|
.map(|rd| rd.filter_map(|e| e.ok()).any(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)))
|
|
.unwrap_or(false);
|
|
if has_subdirs { Some(name) } else { None }
|
|
})
|
|
.collect();
|
|
if locales.is_empty() {
|
|
return None;
|
|
}
|
|
locales.sort();
|
|
tracing::info!("Auto-detected locales from content directory: {:?}", locales);
|
|
Some(locales)
|
|
}
|
|
|
|
fn reload_schemas(
|
|
rt_handle: tokio::runtime::Handle,
|
|
types_dir: PathBuf,
|
|
server_url: String,
|
|
registry: Arc<RwLock<SchemaRegistry>>,
|
|
openapi_spec: Arc<RwLock<serde_json::Value>>,
|
|
cache: Arc<ContentCache>,
|
|
) {
|
|
rt_handle.spawn(async move {
|
|
match SchemaRegistry::load(&types_dir) {
|
|
Ok(new_registry) => {
|
|
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
|
|
*registry.write().await = new_registry;
|
|
*openapi_spec.write().await = spec;
|
|
cache.invalidate_all().await;
|
|
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Hot-reload failed: {}", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
dotenvy::dotenv().ok();
|
|
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| EnvFilter::new("rustycms=info,tower_http=info")),
|
|
)
|
|
.init();
|
|
|
|
let cli = Cli::parse();
|
|
|
|
tracing::info!("Loading schemas from {}", cli.types_dir.display());
|
|
let registry = SchemaRegistry::load(&cli.types_dir)?;
|
|
tracing::info!(
|
|
"Loaded {} schema(s): {:?}",
|
|
registry.names().len(),
|
|
registry.names()
|
|
);
|
|
|
|
let store: std::sync::Arc<dyn ContentStore> = {
|
|
let kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into());
|
|
match kind.as_str() {
|
|
"sqlite" => {
|
|
let url = std::env::var("RUSTYCMS_DATABASE_URL")
|
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
|
.unwrap_or_else(|_| "sqlite:content.db".into());
|
|
tracing::info!("Using SQLite store: {}", url);
|
|
let s = SqliteStore::new(&url).await?;
|
|
std::sync::Arc::new(s)
|
|
}
|
|
_ => {
|
|
tracing::info!("Using file store: {}", cli.content_dir.display());
|
|
let s = FileStore::new(&cli.content_dir);
|
|
std::sync::Arc::new(s)
|
|
}
|
|
}
|
|
};
|
|
|
|
let server_url = format!("http://{}:{}", cli.host, cli.port);
|
|
let openapi_spec = rustycms::api::openapi::generate_spec(®istry, &server_url);
|
|
tracing::info!("OpenAPI spec generated");
|
|
|
|
let registry = Arc::new(RwLock::new(registry));
|
|
let openapi_spec = Arc::new(RwLock::new(openapi_spec));
|
|
|
|
let api_keys = rustycms::api::auth::ApiKeys::from_env();
|
|
if api_keys.as_ref().map(|k| k.is_enabled()).unwrap_or(false) {
|
|
tracing::info!("API key auth enabled (write operations require key with role)");
|
|
}
|
|
|
|
let cache_ttl_secs = std::env::var("RUSTYCMS_CACHE_TTL_SECS")
|
|
.ok()
|
|
.and_then(|s| s.parse().ok())
|
|
.unwrap_or(60);
|
|
let cache = Arc::new(rustycms::api::cache::ContentCache::new(cache_ttl_secs));
|
|
let transform_cache = Arc::new(rustycms::api::cache::TransformCache::new(cache_ttl_secs));
|
|
if cache_ttl_secs > 0 {
|
|
tracing::info!("Response cache enabled, TTL {}s", cache_ttl_secs);
|
|
}
|
|
|
|
let http_client = reqwest::Client::new();
|
|
let locales: Option<Vec<String>> = std::env::var("RUSTYCMS_LOCALES")
|
|
.ok()
|
|
.map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
|
|
.filter(|v: &Vec<String>| !v.is_empty())
|
|
.or_else(|| detect_locales(&cli.content_dir));
|
|
if let Some(ref locs) = locales {
|
|
tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]);
|
|
}
|
|
|
|
let assets_dir = cli.content_dir.join("assets");
|
|
|
|
// RUSTYCMS_ENVIRONMENTS: comma-separated list (e.g. production,staging). File store only.
|
|
// Content for first env = content_dir; others = content_dir/<env>. Assets = content_dir/assets or content_dir/<env>/assets.
|
|
let store_kind = std::env::var("RUSTYCMS_STORE").unwrap_or_else(|_| "file".into());
|
|
let (environments, stores_map, assets_dirs_map, store, assets_dir) = match std::env::var("RUSTYCMS_ENVIRONMENTS").ok().as_deref() {
|
|
Some(s) if !s.trim().is_empty() && store_kind != "sqlite" => {
|
|
let env_list: Vec<String> = s.split(',').map(|e| e.trim().to_string()).filter(|e| !e.is_empty()).collect();
|
|
if env_list.is_empty() {
|
|
(None, None, None, store, assets_dir)
|
|
} else {
|
|
let mut stores = std::collections::HashMap::new();
|
|
let mut assets_dirs = std::collections::HashMap::new();
|
|
for (i, name) in env_list.iter().enumerate() {
|
|
let content_base = if i == 0 {
|
|
cli.content_dir.clone()
|
|
} else {
|
|
cli.content_dir.join(name)
|
|
};
|
|
let assets_path = if i == 0 {
|
|
cli.content_dir.join("assets")
|
|
} else {
|
|
cli.content_dir.join(name).join("assets")
|
|
};
|
|
let s = FileStore::new(&content_base);
|
|
stores.insert(name.clone(), Arc::new(s) as Arc<dyn rustycms::store::ContentStore>);
|
|
assets_dirs.insert(name.clone(), assets_path);
|
|
}
|
|
let default_store = stores.get(&env_list[0]).cloned().unwrap();
|
|
let default_assets = assets_dirs.get(&env_list[0]).cloned().unwrap();
|
|
tracing::info!("Environments enabled: {:?} (default: {})", env_list, &env_list[0]);
|
|
(Some(env_list), Some(stores), Some(assets_dirs), default_store, default_assets)
|
|
}
|
|
}
|
|
_ => {
|
|
if std::env::var("RUSTYCMS_ENVIRONMENTS").is_ok() && store_kind == "sqlite" {
|
|
tracing::warn!("RUSTYCMS_ENVIRONMENTS is ignored when using SQLite store");
|
|
}
|
|
(None, None, None, store, assets_dir)
|
|
}
|
|
};
|
|
|
|
// RUSTYCMS_BASE_URL is the public URL of the API (e.g. https://api.example.com).
|
|
// Used to expand relative /api/assets/ paths to absolute URLs in responses.
|
|
// Falls back to the local server_url (http://host:port).
|
|
let base_url = std::env::var("RUSTYCMS_BASE_URL").unwrap_or_else(|_| server_url.clone());
|
|
|
|
let webhook_urls = rustycms::api::webhooks::urls_from_env();
|
|
if !webhook_urls.is_empty() {
|
|
tracing::info!("Webhooks enabled: {} URL(s)", webhook_urls.len());
|
|
}
|
|
|
|
// Reverse referrer index (file-based in content dir). Only when not using environments (single content root).
|
|
// When the index file is missing, run a full reindex over all collections and save.
|
|
let (referrer_index, referrer_index_path) = if environments.is_none() {
|
|
let path = assets_dir.parent().unwrap().join("_referrers.json");
|
|
let index = if path.exists() {
|
|
ReferrerIndex::load(&path)
|
|
} else {
|
|
tracing::info!("Referrer index not found, building full index from content…");
|
|
let mut index = ReferrerIndex::new();
|
|
let collections_with_schema: Vec<(String, rustycms::schema::types::SchemaDefinition)> = {
|
|
let guard = registry.read().await;
|
|
guard
|
|
.collection_names()
|
|
.into_iter()
|
|
.filter_map(|c| {
|
|
guard.get(&c).filter(|s| !s.reusable).map(|s| (c, s.clone()))
|
|
})
|
|
.collect()
|
|
};
|
|
for (collection, schema) in collections_with_schema {
|
|
let locale_opts: Vec<Option<&str>> = locales
|
|
.as_ref()
|
|
.map(|l| l.iter().map(|s| s.as_str()).map(Some).collect())
|
|
.unwrap_or_else(|| vec![None]);
|
|
for locale_ref in locale_opts {
|
|
match store.list(&collection, locale_ref).await {
|
|
Ok(entries) => {
|
|
for (slug, value) in entries {
|
|
let refs = validator::extract_references(&schema, &value);
|
|
let referrer = Referrer {
|
|
collection: collection.clone(),
|
|
slug: slug.clone(),
|
|
field: String::new(),
|
|
locale: locale_ref.map(str::to_string),
|
|
};
|
|
for (ref_coll, ref_slug, field) in refs {
|
|
let mut r = referrer.clone();
|
|
r.field = field;
|
|
index.add_referrer(&ref_coll, &ref_slug, r);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!("List {} (locale {:?}) failed: {}", collection, locale_ref, e),
|
|
}
|
|
}
|
|
}
|
|
if let Err(e) = index.save(&path) {
|
|
tracing::warn!("Failed to save referrer index: {}", e);
|
|
} else {
|
|
tracing::info!("Referrer index saved to {}", path.display());
|
|
}
|
|
index
|
|
};
|
|
if path.exists() {
|
|
tracing::info!("Referrer index loaded from {}", path.display());
|
|
}
|
|
(Some(Arc::new(RwLock::new(index))), Some(path))
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let state = Arc::new(AppState {
|
|
registry: Arc::clone(®istry),
|
|
store,
|
|
openapi_spec: Arc::clone(&openapi_spec),
|
|
types_dir: cli.types_dir.clone(),
|
|
api_keys,
|
|
cache: Arc::clone(&cache),
|
|
transform_cache,
|
|
http_client,
|
|
locales,
|
|
assets_dir,
|
|
base_url,
|
|
webhook_urls,
|
|
environments,
|
|
stores: stores_map,
|
|
assets_dirs: assets_dirs_map,
|
|
referrer_index,
|
|
referrer_index_path,
|
|
});
|
|
|
|
// Hot-reload: watch types_dir and reload schemas on change (run reload on main Tokio runtime from watcher thread)
|
|
let rt_handle = tokio::runtime::Handle::current();
|
|
let types_dir_for_callback = cli.types_dir.canonicalize().unwrap_or_else(|_| cli.types_dir.clone());
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
let mut watcher = RecommendedWatcher::new(
|
|
move |res: Result<notify::Event, notify::Error>| {
|
|
if let Ok(ev) = res {
|
|
if ev.kind.is_modify() || ev.kind.is_create() || ev.kind.is_remove() {
|
|
// Any change under types_dir triggers reload (robust for editors that use temp files)
|
|
let under_types = ev.paths.iter().any(|p| {
|
|
p.canonicalize()
|
|
.map(|c| c.starts_with(&types_dir_for_callback))
|
|
.unwrap_or_else(|_| {
|
|
p.extension()
|
|
.map(|e| e == "json5" || e == "json")
|
|
.unwrap_or(false)
|
|
})
|
|
});
|
|
if under_types {
|
|
let _ = tx.send(());
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Config::default(),
|
|
)?;
|
|
watcher.watch(&cli.types_dir, RecursiveMode::Recursive)?;
|
|
let types_dir_watch = cli.types_dir.clone();
|
|
let server_url_watch = server_url.clone();
|
|
std::thread::spawn(move || {
|
|
let _watcher = watcher;
|
|
while rx.recv().is_ok() {
|
|
// Debounce: wait for editor to finish writing, drain extra events, then reload once
|
|
std::thread::sleep(Duration::from_millis(800));
|
|
while rx.try_recv().is_ok() {}
|
|
reload_schemas(
|
|
rt_handle.clone(),
|
|
types_dir_watch.clone(),
|
|
server_url_watch.clone(),
|
|
Arc::clone(®istry),
|
|
Arc::clone(&openapi_spec),
|
|
Arc::clone(&cache),
|
|
);
|
|
}
|
|
});
|
|
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());
|
|
|
|
let cors = match std::env::var("RUSTYCMS_CORS_ORIGIN") {
|
|
Ok(s) if s.trim().is_empty() || s.trim() == "*" => CorsLayer::permissive(),
|
|
Ok(s) => {
|
|
let o = s.trim().to_string();
|
|
match HeaderValue::try_from(o) {
|
|
Ok(h) => CorsLayer::new().allow_origin(AllowOrigin::exact(h)),
|
|
Err(_) => CorsLayer::permissive(),
|
|
}
|
|
}
|
|
Err(_) => CorsLayer::permissive(),
|
|
};
|
|
|
|
let trace = TraceLayer::new_for_http()
|
|
.make_span_with(|request: &axum::http::Request<axum::body::Body>| {
|
|
let method = request.method().as_str();
|
|
let uri = request
|
|
.uri()
|
|
.path_and_query()
|
|
.map(|pq| pq.as_str().to_string())
|
|
.unwrap_or_else(|| request.uri().path().to_string());
|
|
info_span!(
|
|
"request",
|
|
method = %method,
|
|
uri = %uri,
|
|
)
|
|
})
|
|
.on_response(
|
|
DefaultOnResponse::new()
|
|
.level(Level::INFO)
|
|
.latency_unit(tower_http::LatencyUnit::Millis),
|
|
);
|
|
let app = rustycms::api::routes::create_router(state)
|
|
.layer(cors)
|
|
.layer(trace);
|
|
|
|
let addr = format!("{}:{}", cli.host, cli.port);
|
|
tracing::info!("RustyCMS v0.1.0 listening on http://{}", addr);
|
|
tracing::info!("Swagger UI: http://{}/swagger-ui", addr);
|
|
|
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
|
|
let shutdown = async {
|
|
#[cfg(unix)]
|
|
{
|
|
if let Ok(mut sig) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
|
tokio::select! {
|
|
_ = tokio::signal::ctrl_c() => {}
|
|
_ = sig.recv() => {}
|
|
}
|
|
} else {
|
|
tokio::signal::ctrl_c().await.ok();
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
tokio::signal::ctrl_c().await.ok();
|
|
}
|
|
tracing::info!("Shutdown signal received, draining requests...");
|
|
};
|
|
axum::serve(listener, app)
|
|
.with_graceful_shutdown(shutdown)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|