RustyCMS: file-based headless CMS — API, Admin UI (content, types, assets), Docker/Caddy, image transform; only demo type and demo content in version control

Made-with: Cursor
This commit is contained in:
Peter Meier
2026-03-12 14:21:49 +01:00
parent aad93d145f
commit 7795a238e1
278 changed files with 15551 additions and 4072 deletions

542
src/api/assets.rs Normal file
View File

@@ -0,0 +1,542 @@
//! Asset management with single-level folder support.
//!
//! Assets are stored in `content/assets/` with optional subdirectories.
//!
//! Endpoints:
//! GET /api/assets list all assets (no ?folder = all, ?folder=name, ?folder= = root only)
//! POST /api/assets[?folder=name] upload image (multipart/form-data, auth required)
//! GET /api/assets/folders list all folders with asset counts
//! POST /api/assets/folders create folder { "name": "..." } (auth required)
//! DELETE /api/assets/folders/:name delete empty folder (auth required)
//! GET /api/assets/*path serve image (e.g. /api/assets/blog/hero.jpg)
//! PATCH /api/assets/*path rename image (body: { "filename": "newname.jpg" }, auth required)
//! DELETE /api/assets/*path delete image (auth required)
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::fs;
use super::auth;
use super::error::ApiError;
use super::handlers::AppState;
const ALLOWED_EXTENSIONS: &[(&str, &str)] = &[
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("png", "image/png"),
("webp", "image/webp"),
("avif", "image/avif"),
("gif", "image/gif"),
("svg", "image/svg+xml"),
];
fn mime_for_ext(ext: &str) -> Option<&'static str> {
ALLOWED_EXTENSIONS
.iter()
.find(|(e, _)| *e == ext)
.map(|(_, m)| *m)
}
/// Sanitize a single path segment: lowercase, alphanumeric + dash + underscore + dot.
fn sanitize_segment(name: &str) -> Result<String, ApiError> {
let name = name.trim();
if name.is_empty() {
return Err(ApiError::BadRequest("Name is empty".into()));
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(ApiError::BadRequest("Invalid name".into()));
}
let lower = name.to_lowercase();
if !lower
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(ApiError::BadRequest(
"Name may only contain letters, digits, hyphens, underscores, and dots".into(),
));
}
Ok(lower)
}
fn validate_filename(name: &str) -> Result<String, ApiError> {
let lower = sanitize_segment(name)?;
let ext = lower.rsplit('.').next().unwrap_or("");
if mime_for_ext(ext).is_none() {
return Err(ApiError::BadRequest(format!(
"File type '.{}' is not allowed. Allowed: jpg, jpeg, png, webp, avif, gif, svg",
ext
)));
}
Ok(lower)
}
fn validate_folder_name(name: &str) -> Result<String, ApiError> {
let lower = sanitize_segment(name)?;
if lower.contains('.') {
return Err(ApiError::BadRequest(
"Folder names may not contain dots".into(),
));
}
Ok(lower)
}
/// Parse a wildcard path into (folder, filename).
/// "hero.jpg" → (None, "hero.jpg")
/// "blog/hero.jpg" → (Some("blog"), "hero.jpg")
fn parse_path(path: &str) -> Result<(Option<String>, String), ApiError> {
let path = path.trim_start_matches('/');
let mut parts = path.splitn(2, '/');
match (parts.next(), parts.next()) {
(Some(a), None) => Ok((None, validate_filename(a)?)),
(Some(folder), Some(filename)) => {
Ok((Some(validate_folder_name(folder)?), validate_filename(filename)?))
}
_ => Err(ApiError::BadRequest("Invalid asset path".into())),
}
}
/// Read all image files from a directory, tagging them with the folder name.
async fn read_images(
dir: &std::path::Path,
folder: Option<&str>,
) -> Result<Vec<Value>, ApiError> {
if !dir.exists() {
return Ok(vec![]);
}
let mut entries = Vec::new();
let mut rd = fs::read_dir(dir)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if ft.is_dir() {
continue;
}
let fname = e.file_name().to_string_lossy().to_string();
let ext = fname.rsplit('.').next().unwrap_or("").to_lowercase();
if mime_for_ext(&ext).is_none() {
continue;
}
let size = e.metadata().await.map(|m| m.len()).unwrap_or(0);
let mime = mime_for_ext(&ext).unwrap_or("application/octet-stream");
let url = match folder {
Some(f) => format!("/api/assets/{}/{}", f, fname),
None => format!("/api/assets/{}", fname),
};
entries.push(json!({
"filename": fname,
"folder": folder,
"url": url,
"mime_type": mime,
"size": size,
}));
}
Ok(entries)
}
// ---------------------------------------------------------------------------
// GET /api/assets
// ?folder not present → all assets (root + all subdirectories)
// ?folder=name → only that folder
// ?folder= → only root (no folder)
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct ListAssetsParams {
folder: Option<String>,
}
pub async fn list_assets(
State(state): State<Arc<AppState>>,
Query(params): Query<ListAssetsParams>,
) -> Result<Json<Value>, ApiError> {
let base = &state.assets_dir;
if !base.exists() {
fs::create_dir_all(base)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
}
let mut all: Vec<Value> = Vec::new();
match params.folder.as_deref() {
// Specific named folder
Some(f) if !f.is_empty() => {
let folder_name = validate_folder_name(f)?;
let dir = base.join(&folder_name);
all.extend(read_images(&dir, Some(&folder_name)).await?);
}
// Root only (folder= with empty value)
Some(_empty) => {
all.extend(read_images(base, None).await?);
}
// All: root + every subdirectory
None => {
all.extend(read_images(base, None).await?);
let mut rd = fs::read_dir(base)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ft.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
let dir = base.join(&name);
all.extend(read_images(&dir, Some(&name)).await?);
}
}
}
// Sort: by folder, then filename
all.sort_by(|a, b| {
let fa = a["folder"].as_str().unwrap_or("");
let fb = b["folder"].as_str().unwrap_or("");
let na = a["filename"].as_str().unwrap_or("");
let nb = b["filename"].as_str().unwrap_or("");
fa.cmp(fb).then(na.cmp(nb))
});
let total = all.len();
Ok(Json(json!({ "assets": all, "total": total })))
}
// ---------------------------------------------------------------------------
// GET /api/assets/folders
// ---------------------------------------------------------------------------
pub async fn list_folders(
State(state): State<Arc<AppState>>,
) -> Result<Json<Value>, ApiError> {
let base = &state.assets_dir;
if !base.exists() {
fs::create_dir_all(base)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create assets dir: {}", e)))?;
}
let mut folders: Vec<Value> = Vec::new();
let mut rd = fs::read_dir(base)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
while let Some(e) = rd
.next_entry()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
{
let ft = e
.file_type()
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if !ft.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
let mut count = 0u64;
if let Ok(mut sub) = fs::read_dir(base.join(&name)).await {
while let Ok(Some(se)) = sub.next_entry().await {
let ext = se
.file_name()
.to_string_lossy()
.rsplit('.')
.next()
.unwrap_or("")
.to_lowercase();
if mime_for_ext(&ext).is_some() {
count += 1;
}
}
}
folders.push(json!({ "name": name, "count": count }));
}
folders.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
Ok(Json(json!({ "folders": folders })))
}
// ---------------------------------------------------------------------------
// POST /api/assets/folders { "name": "blog" }
// ---------------------------------------------------------------------------
pub async fn create_folder(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let name = body["name"]
.as_str()
.ok_or_else(|| ApiError::BadRequest("Missing 'name' field".into()))?;
let name = validate_folder_name(name)?;
let path = state.assets_dir.join(&name);
if path.exists() {
return Err(ApiError::Conflict(format!(
"Folder '{}' already exists",
name
)));
}
fs::create_dir_all(&path)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create folder: {}", e)))?;
Ok((StatusCode::CREATED, Json(json!({ "name": name }))).into_response())
}
// ---------------------------------------------------------------------------
// DELETE /api/assets/folders/:name
// ---------------------------------------------------------------------------
pub async fn delete_folder(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(name): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let name = validate_folder_name(&name)?;
let path = state.assets_dir.join(&name);
if !path.exists() {
return Err(ApiError::NotFound(format!("Folder '{}' not found", name)));
}
// remove_dir only succeeds when directory is empty
fs::remove_dir(&path).await.map_err(|_| {
ApiError::BadRequest(format!(
"Folder '{}' is not empty delete all images first",
name
))
})?;
Ok(StatusCode::NO_CONTENT.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/assets[?folder=name]
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct UploadParams {
folder: Option<String>,
}
pub async fn upload_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(params): Query<UploadParams>,
mut multipart: Multipart,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let folder = match params.folder.as_deref() {
Some(f) if !f.is_empty() => Some(validate_folder_name(f)?),
_ => None,
};
let dir = match &folder {
Some(f) => state.assets_dir.join(f),
None => state.assets_dir.clone(),
};
if !dir.exists() {
fs::create_dir_all(&dir)
.await
.map_err(|e| ApiError::Internal(format!("Cannot create dir: {}", e)))?;
}
let field = {
let mut found = None;
while let Some(f) = multipart
.next_field()
.await
.map_err(|e| ApiError::BadRequest(e.to_string()))?
{
found = Some(f);
break;
}
found.ok_or_else(|| ApiError::BadRequest("No file field found".into()))?
};
let original_name = field
.file_name()
.map(|s| s.to_string())
.or_else(|| field.name().map(|s| s.to_string()))
.unwrap_or_else(|| "upload".to_string());
let filename = validate_filename(&original_name)?;
let dest = dir.join(&filename);
if dest.exists() {
return Err(ApiError::Conflict(format!(
"Asset '{}' already exists",
filename
)));
}
let data = field
.bytes()
.await
.map_err(|e| ApiError::BadRequest(format!("Failed to read file: {}", e)))?;
if data.is_empty() {
return Err(ApiError::BadRequest("File is empty".into()));
}
fs::write(&dest, &data)
.await
.map_err(|e| ApiError::Internal(format!("Failed to save file: {}", e)))?;
let ext = filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let url = match &folder {
Some(f) => format!("/api/assets/{}/{}", f, filename),
None => format!("/api/assets/{}", filename),
};
Ok((
StatusCode::CREATED,
Json(json!({
"filename": filename,
"folder": folder,
"url": url,
"mime_type": mime,
"size": data.len(),
})),
)
.into_response())
}
// ---------------------------------------------------------------------------
// GET /api/assets/*path
// ---------------------------------------------------------------------------
pub async fn get_asset(
State(state): State<Arc<AppState>>,
AxumPath(path): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
let (folder, filename) = parse_path(&path)?;
let file_path = match &folder {
Some(f) => state.assets_dir.join(f).join(&filename),
None => state.assets_dir.join(&filename),
};
if !file_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
let data = fs::read(&file_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to read asset: {}", e)))?;
let ext = filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(
header::CACHE_CONTROL,
"public, max-age=31536000, immutable",
)
.body(Body::from(data))
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(response)
}
// ---------------------------------------------------------------------------
// PATCH /api/assets/*path { "filename": "newname.jpg" }
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct RenameBody {
pub filename: String,
}
pub async fn rename_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(path): AxumPath<String>,
Json(body): Json<RenameBody>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let (folder, old_filename) = parse_path(&path)?;
let new_filename = validate_filename(&body.filename)?;
if new_filename == old_filename {
return Err(ApiError::BadRequest("New filename is the same as current".into()));
}
let base = match &folder {
Some(f) => state.assets_dir.join(f),
None => state.assets_dir.clone(),
};
let old_path = base.join(&old_filename);
let new_path = base.join(&new_filename);
if !old_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
if new_path.exists() {
return Err(ApiError::Conflict(format!(
"Asset '{}' already exists",
new_filename
)));
}
fs::rename(&old_path, &new_path)
.await
.map_err(|e| ApiError::Internal(format!("Rename failed: {}", e)))?;
let url = match &folder {
Some(f) => format!("/api/assets/{}/{}", f, new_filename),
None => format!("/api/assets/{}", new_filename),
};
let ext = new_filename.rsplit('.').next().unwrap_or("");
let mime = mime_for_ext(ext).unwrap_or("application/octet-stream");
let meta = fs::metadata(&new_path)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok((
StatusCode::OK,
Json(json!({
"filename": new_filename,
"folder": folder,
"url": url,
"mime_type": mime,
"size": meta.len(),
})),
)
.into_response())
}
// ---------------------------------------------------------------------------
// DELETE /api/assets/*path
// ---------------------------------------------------------------------------
pub async fn delete_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
AxumPath(path): AxumPath<String>,
) -> Result<axum::response::Response, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
let (folder, filename) = parse_path(&path)?;
let file_path = match &folder {
Some(f) => state.assets_dir.join(f).join(&filename),
None => state.assets_dir.join(&filename),
};
if !file_path.exists() {
return Err(ApiError::NotFound(format!("Asset '{}' not found", path)));
}
fs::remove_file(&file_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete asset: {}", e)))?;
Ok(StatusCode::NO_CONTENT.into_response())
}

View File

@@ -21,7 +21,7 @@ use crate::store::slug;
use super::auth;
use super::cache::{self, ContentCache, TransformCache};
use super::error::ApiError;
use super::response::{format_references, parse_resolve};
use super::response::{collapse_asset_urls, expand_asset_urls, format_references, parse_resolve};
/// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload.
/// Store is selected via RUSTYCMS_STORE=file|sqlite.
@@ -39,6 +39,10 @@ pub struct AppState {
pub http_client: reqwest::Client,
/// If set, first element is default locale. Enables content/{locale}/{collection}/.
pub locales: Option<Vec<String>>,
/// Path to the assets directory (e.g. ./content/assets) for image storage.
pub assets_dir: PathBuf,
/// Public base URL (e.g. https://api.example.com). Used to expand relative /api/assets/ paths.
pub base_url: String,
}
/// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off.
@@ -158,6 +162,101 @@ pub async fn create_schema(
))
}
// ---------------------------------------------------------------------------
// PUT /api/schemas/:name update existing schema
// ---------------------------------------------------------------------------
pub async fn update_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
Json(schema): Json<SchemaDefinition>,
) -> Result<Json<Value>, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if name != schema.name {
return Err(ApiError::BadRequest(
"Path name must match schema.name".to_string(),
));
}
if !is_valid_schema_name(&schema.name) {
return Err(ApiError::BadRequest(
"name: lowercase letters, digits and underscore only, max 64 chars".to_string(),
));
}
let mut errors = Vec::new();
for (field_name, fd) in &schema.fields {
if !VALID_FIELD_TYPES.contains(&fd.field_type.as_str()) {
errors.push(format!(
"Field '{}': unknown type '{}'",
field_name, fd.field_type
));
}
}
if !errors.is_empty() {
return Err(ApiError::ValidationFailed(errors));
}
let json5_path = state.types_dir.join(format!("{}.json5", name));
let json_path = state.types_dir.join(format!("{}.json", name));
let path = if json5_path.exists() {
json5_path
} else if json_path.exists() {
json_path
} else {
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
};
let contents = serde_json::to_string_pretty(&schema).map_err(|e| ApiError::Internal(e.to_string()))?;
tokio::fs::write(&path, contents)
.await
.map_err(|e| ApiError::Internal(format!("Failed to write schema file: {}", e)))?;
tracing::info!("Schema updated: {} ({})", name, path.display());
Ok(Json(serde_json::to_value(&schema).unwrap()))
}
// ---------------------------------------------------------------------------
// DELETE /api/schemas/:name delete schema file
// ---------------------------------------------------------------------------
pub async fn delete_schema(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if !is_valid_schema_name(&name) {
return Err(ApiError::BadRequest("Invalid schema name".to_string()));
}
let json5_path = state.types_dir.join(format!("{}.json5", name));
let json_path = state.types_dir.join(format!("{}.json", name));
let mut deleted = false;
if json5_path.exists() {
tokio::fs::remove_file(&json5_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete schema file: {}", e)))?;
deleted = true;
tracing::info!("Schema deleted: {} ({})", name, json5_path.display());
}
if json_path.exists() {
tokio::fs::remove_file(&json_path)
.await
.map_err(|e| ApiError::Internal(format!("Failed to delete schema file: {}", e)))?;
deleted = true;
tracing::info!("Schema deleted: {} ({})", name, json_path.display());
}
if !deleted {
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
}
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// GET /api/collections/:collection
// ---------------------------------------------------------------------------
@@ -270,13 +369,10 @@ pub async fn list_entries(
Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
let registry = state.registry.read().await;
let schema = registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?;
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
@@ -287,12 +383,24 @@ pub async fn list_entries(
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let mut keys: Vec<_> = params.keys().collect();
// Apply schema default sort when client does not send _sort
let mut list_params = params.clone();
if !list_params.contains_key("_sort") {
if let Some(ref sort) = schema.default_sort {
list_params.insert("_sort".to_string(), sort.clone());
list_params.insert(
"_order".to_string(),
schema.default_order.clone().unwrap_or_else(|| "desc".to_string()),
);
}
}
let mut keys: Vec<_> = list_params.keys().collect();
keys.sort();
let mut hasher = DefaultHasher::new();
for k in &keys {
k.hash(&mut hasher);
params[*k].hash(&mut hasher);
list_params.get(k.as_str()).unwrap().hash(&mut hasher);
}
let query_hash = hasher.finish();
let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref);
@@ -302,19 +410,23 @@ pub async fn list_entries(
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(params);
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(list_params);
let mut result = query.apply(entries);
for item in result.items.iter_mut() {
*item = format_references(
std::mem::take(item),
&schema,
schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
)
.await;
if resolve.is_some() {
expand_asset_urls(item, &state.base_url);
}
}
let response_value = serde_json::to_value(&result).unwrap();
@@ -332,13 +444,10 @@ pub async fn get_entry(
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let schema = {
let registry = state.registry.read().await;
registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?
.clone()
};
let registry = state.registry.read().await;
let schema = registry
.get(&collection)
.ok_or_else(|| ApiError::NotFound(format!("Collection '{}' not found", collection)))?;
if schema.reusable {
return Err(ApiError::NotFound(format!(
"Collection '{}' is a reusable partial, not a content collection",
@@ -387,14 +496,18 @@ pub async fn get_entry(
})?;
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let formatted = format_references(
let mut formatted = format_references(
entry,
&schema,
schema,
state.store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
)
.await;
if resolve.is_some() {
expand_asset_urls(&mut formatted, &state.base_url);
}
state
.cache
@@ -476,6 +589,9 @@ pub async fn create_entry(
// Apply defaults and auto-generated values
validator::apply_defaults(&schema, &mut body);
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
// Validate against schema (type checks, constraints, strict mode, …)
let errors = validator::validate_content(&schema, &body);
if !errors.is_empty() {
@@ -505,6 +621,9 @@ pub async fn create_entry(
return Err(ApiError::ValidationFailed(messages));
}
// Normalize absolute asset URLs → relative before persisting
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
@@ -520,7 +639,15 @@ pub async fn create_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
let mut formatted = format_references(
entry,
&schema,
state.store.as_ref(),
None,
locale_ref,
None,
)
.await;
Ok((StatusCode::CREATED, Json(formatted)))
}
@@ -560,6 +687,9 @@ pub async fn update_entry(
obj.remove("_slug");
}
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
validator::normalize_reference_arrays(&schema, &mut body);
// Load existing content for readonly check
let existing = state
.store
@@ -606,6 +736,9 @@ pub async fn update_entry(
return Err(ApiError::ValidationFailed(messages));
}
// Normalize absolute asset URLs → relative before persisting
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
@@ -621,11 +754,35 @@ pub async fn update_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let formatted = format_references(entry, &schema, state.store.as_ref(), None, locale_ref).await;
let mut formatted = format_references(
entry,
&schema,
state.store.as_ref(),
None,
locale_ref,
None,
)
.await;
Ok(Json(formatted))
}
// ---------------------------------------------------------------------------
// GET /api/locales
// ---------------------------------------------------------------------------
pub async fn list_locales(State(state): State<Arc<AppState>>) -> Json<Value> {
match &state.locales {
Some(locales) if !locales.is_empty() => Json(json!({
"locales": locales,
"default": locales[0],
})),
_ => Json(json!({
"locales": [],
"default": null,
})),
}
}
// ---------------------------------------------------------------------------
// DELETE /api/content/:collection/:slug
// ---------------------------------------------------------------------------

View File

@@ -1,3 +1,4 @@
pub mod assets;
pub mod auth;
pub mod cache;
pub mod error;

View File

@@ -237,6 +237,122 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
);
}
// ── Asset management ─────────────────────────────────────────────────
paths.insert(
"/api/assets".to_string(),
json!({
"get": {
"summary": "List all assets",
"description": "Returns all image files stored in `content/assets/`.",
"operationId": "listAssets",
"tags": ["Assets"],
"responses": {
"200": {
"description": "List of assets",
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"assets": {
"type": "array",
"items": { "$ref": "#/components/schemas/Asset" }
},
"total": { "type": "integer" }
}
}}}
}
}
},
"post": {
"summary": "Upload an image asset",
"description": "Upload an image file (jpg, jpeg, png, webp, avif, gif, svg) to `content/assets/`. Requires API key when `RUSTYCMS_API_KEY` is set.",
"operationId": "uploadAsset",
"tags": ["Assets"],
"security": [{ "ApiKeyAuth": [] }],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"file": { "type": "string", "format": "binary", "description": "Image file to upload" }
},
"required": ["file"]
}
}
}
},
"responses": {
"201": {
"description": "Asset uploaded",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Asset" } } }
},
"400": { "description": "Invalid file or disallowed extension" },
"401": { "description": "Missing or invalid API key" },
"409": { "description": "Asset with this filename already exists" }
}
}
}),
);
paths.insert(
"/api/assets/{filename}".to_string(),
json!({
"get": {
"summary": "Serve an asset",
"description": "Returns the raw image file with the appropriate `Content-Type` header. Cached by browsers for 1 year (`Cache-Control: immutable`).",
"operationId": "getAsset",
"tags": ["Assets"],
"parameters": [
{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Asset filename (e.g. hero.jpg)" }
],
"responses": {
"200": {
"description": "Image file",
"content": {
"image/jpeg": { "schema": { "type": "string", "format": "binary" } },
"image/png": { "schema": { "type": "string", "format": "binary" } },
"image/webp": { "schema": { "type": "string", "format": "binary" } },
"image/avif": { "schema": { "type": "string", "format": "binary" } },
"image/gif": { "schema": { "type": "string", "format": "binary" } },
"image/svg+xml": { "schema": { "type": "string", "format": "binary" } }
}
},
"400": { "description": "Invalid filename" },
"404": { "description": "Asset not found" }
}
},
"delete": {
"summary": "Delete an asset",
"description": "Deletes an image file from `content/assets/`. Requires API key when `RUSTYCMS_API_KEY` is set.",
"operationId": "deleteAsset",
"tags": ["Assets"],
"security": [{ "ApiKeyAuth": [] }],
"parameters": [
{ "name": "filename", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Asset filename (e.g. hero.jpg)" }
],
"responses": {
"204": { "description": "Asset deleted" },
"401": { "description": "Missing or invalid API key" },
"404": { "description": "Asset not found" }
}
}
}),
);
schemas.insert(
"Asset".to_string(),
json!({
"type": "object",
"properties": {
"filename": { "type": "string", "description": "Filename of the asset" },
"url": { "type": "string", "description": "URL path to serve the asset (e.g. /api/assets/hero.jpg)" },
"mime_type":{ "type": "string", "description": "MIME type (e.g. image/jpeg)" },
"size": { "type": "integer", "description": "File size in bytes" }
}
}),
);
// ── GET /api/transform (Image transformation) ─────────────────────────
paths.insert(
"/api/transform".to_string(),
@@ -253,7 +369,7 @@ pub fn generate_spec(registry: &SchemaRegistry, server_url: &str) -> Value {
{ "name": "ar", "in": "query", "required": false, "schema": { "type": "string", "example": "1:1" }, "description": "Aspect ratio before resize, e.g. 1:1 or 16:9 (center crop)" },
{ "name": "fit", "in": "query", "required": false, "schema": { "type": "string", "enum": ["fill", "contain", "cover"], "default": "contain" }, "description": "fill = exact w×h, contain = fit inside, cover = fill with crop" },
{ "name": "format", "in": "query", "required": false, "schema": { "type": "string", "enum": ["jpeg", "png", "webp", "avif"], "default": "jpeg" }, "description": "Output format" },
{ "name": "quality", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 85 }, "description": "JPEG quality (1100)" }
{ "name": "quality", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 85 }, "description": "Quality 1100 for JPEG and WebP (lossy)" }
],
"responses": {
"200": {
@@ -353,7 +469,7 @@ fn build_input_schema(fields: &IndexMap<String, FieldDefinition>) -> Value {
fn field_to_json_schema(fd: &FieldDefinition) -> Value {
let mut schema = match fd.field_type.as_str() {
"string" => json!({ "type": "string" }),
"richtext" | "html" | "markdown" => json!({ "type": "string" }),
"richtext" | "html" | "markdown" | "textOrRef" => json!({ "type": "string" }),
"number" => json!({ "type": "number" }),
"integer" => json!({ "type": "integer" }),
"boolean" => json!({ "type": "boolean" }),
@@ -386,6 +502,26 @@ fn field_to_json_schema(fd: &FieldDefinition) -> Value {
};
json!({ "type": "string", "description": desc })
},
"referenceOrInline" => {
let ref_desc = if let Some(ref list) = fd.collections {
if list.is_empty() {
"Reference (slug)".to_string()
} else {
format!("Reference (slug) to one of: {}", list.join(", "))
}
} else if let Some(ref c) = fd.collection {
format!("Reference (slug) to collection '{}'", c)
} else {
"Reference (slug)".to_string()
};
let ref_schema = json!({ "type": "string", "description": ref_desc });
let inline_schema = if let Some(ref nested) = fd.fields {
fields_to_json_schema(nested, false)
} else {
json!({ "type": "object" })
};
json!({ "oneOf": [ ref_schema, inline_schema ] })
},
_ => json!({ "type": "string" }),
};
@@ -490,6 +626,7 @@ fn query_parameters(fields: &IndexMap<String, FieldDefinition>) -> Value {
fn build_tags(registry: &SchemaRegistry) -> Value {
let mut tags = vec![
json!({ "name": "Collections", "description": "Schema / type management" }),
json!({ "name": "Assets", "description": "Image asset management upload, serve, and delete files stored in content/assets/" }),
json!({ "name": "Transform", "description": "Image transformation from external URL (resize, crop, format)" }),
];
for (name, schema) in registry.list().iter().filter(|(_, s)| !s.reusable) {

View File

@@ -1,10 +1,40 @@
//! Response formatting: reference fields as { _type, _slug } and optional _resolve.
//! When a registry is provided, resolved nested entries are recursively formatted
//! so their reference fields (e.g. filterByTag in post_overview) are also resolved.
use serde_json::{json, Value};
use crate::schema::types::SchemaDefinition;
use crate::schema::SchemaRegistry;
use crate::store::ContentStore;
/// Recursively expand relative `/api/assets/` paths to absolute URLs.
/// Idempotent: strings already starting with a scheme (http/https) are skipped.
pub fn expand_asset_urls(value: &mut Value, base_url: &str) {
match value {
Value::String(s) if s.starts_with("/api/assets/") => {
*s = format!("{}{}", base_url.trim_end_matches('/'), s);
}
Value::Array(arr) => arr.iter_mut().for_each(|v| expand_asset_urls(v, base_url)),
Value::Object(map) => map.values_mut().for_each(|v| expand_asset_urls(v, base_url)),
_ => {}
}
}
/// Reverse of expand_asset_urls: strip the base_url prefix from absolute asset URLs
/// before persisting to disk, so stored paths are always relative.
pub fn collapse_asset_urls(value: &mut Value, base_url: &str) {
let prefix = format!("{}/api/assets/", base_url.trim_end_matches('/'));
match value {
Value::String(s) if s.starts_with(&prefix) => {
*s = format!("/api/assets/{}", &s[prefix.len()..]);
}
Value::Array(arr) => arr.iter_mut().for_each(|v| collapse_asset_urls(v, base_url)),
Value::Object(map) => map.values_mut().for_each(|v| collapse_asset_urls(v, base_url)),
_ => {}
}
}
/// Parse _resolve query param: "all" or "field1,field2".
pub fn parse_resolve(resolve_param: Option<&str>) -> Option<ResolveSet> {
let s = resolve_param?.trim();
@@ -43,12 +73,15 @@ impl ResolveSet {
/// Format an entry for API response: reference fields become { _type, _slug };
/// if resolve set includes the field (or all), embed the referenced entry.
/// When locale is set, resolved references are loaded from that locale.
/// When registry is set, embedded entries are recursively formatted so their
/// reference fields (e.g. filterByTag) are also resolved or expanded to { _type, _slug }.
pub async fn format_references(
mut entry: Value,
schema: &SchemaDefinition,
store: &dyn ContentStore,
resolve: Option<&ResolveSet>,
locale: Option<&str>,
registry: Option<&SchemaRegistry>,
) -> Value {
let obj = match entry.as_object_mut() {
Some(o) => o,
@@ -56,12 +89,12 @@ pub async fn format_references(
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "reference" {
let is_ref_field = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref_field {
let colls = fd.reference_collections();
if !colls.is_empty() && obj.get(field_name).and_then(|v| v.as_str()).is_some() {
let slug = obj.get(field_name).and_then(|v| v.as_str()).unwrap();
let first_coll = colls[0];
let ref_obj = json!({ "_type": first_coll, "_slug": slug });
let should_resolve =
resolve.map(|r| r.should_resolve(field_name)).unwrap_or(false);
if should_resolve {
@@ -72,34 +105,66 @@ pub async fn format_references(
res_obj.insert("_type".to_string(), Value::String(coll.to_string()));
res_obj.insert("_slug".to_string(), Value::String(slug.to_string()));
}
if let Some(reg) = registry {
if let Some(ref_schema) = reg.get(coll) {
resolved = Box::pin(format_references(
resolved,
ref_schema,
store,
resolve,
locale,
registry,
))
.await;
}
}
resolved_opt = Some((coll.to_string(), resolved));
break;
}
}
let ref_obj = json!({ "_type": colls[0], "_slug": slug });
obj.insert(
field_name.clone(),
resolved_opt
.map(|(_, v)| v)
.unwrap_or_else(|| ref_obj.clone()),
.unwrap_or_else(|| ref_obj),
);
} else {
let mut found_coll = None;
for coll in &colls {
if store.get(coll, slug, locale).await.is_ok_and(|r| r.is_some()) {
found_coll = Some(coll.to_string());
break;
}
}
let ref_obj = found_coll
.map(|c| json!({ "_type": c, "_slug": slug }))
.unwrap_or_else(|| json!({ "_type": colls[0], "_slug": slug }));
obj.insert(field_name.clone(), ref_obj);
}
}
continue;
}
if fd.field_type == "referenceOrInline" {
if let Some(o) = obj.get_mut(field_name).and_then(|v| v.as_object_mut()) {
let colls = fd.reference_collections();
if let Some(coll) = colls.first() {
o.insert("_type".to_string(), Value::String((*coll).to_string()));
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let colls = items.reference_collections();
if items.field_type == "reference" && !colls.is_empty() {
let is_ref_item = items.field_type == "reference" || items.field_type == "referenceOrInline";
if is_ref_item && !colls.is_empty() {
if let Some(arr) = obj.get_mut(field_name).and_then(|v| v.as_array_mut()) {
let should_resolve =
resolve.map(|r| r.should_resolve(field_name)).unwrap_or(false);
let first_coll = colls[0];
let mut new_arr = Vec::new();
for item in arr.drain(..) {
for mut item in arr.drain(..) {
if let Some(slug) = item.as_str() {
let ref_obj = json!({ "_type": first_coll, "_slug": slug });
if should_resolve {
let mut resolved_opt = None;
for coll in &colls {
@@ -115,14 +180,45 @@ pub async fn format_references(
Value::String(slug.to_string()),
);
}
if let Some(reg) = registry {
if let Some(ref_schema) = reg.get(coll) {
resolved = Box::pin(format_references(
resolved,
ref_schema,
store,
resolve,
locale,
registry,
))
.await;
}
}
resolved_opt = Some(resolved);
break;
}
}
let ref_obj = json!({ "_type": colls[0], "_slug": slug });
new_arr.push(resolved_opt.unwrap_or(ref_obj));
} else {
let mut found_coll = None;
for coll in &colls {
if store.get(coll, slug, locale).await.is_ok_and(|r| r.is_some()) {
found_coll = Some(coll.to_string());
break;
}
}
let ref_obj = found_coll
.map(|c| json!({ "_type": c, "_slug": slug }))
.unwrap_or_else(|| json!({ "_type": colls[0], "_slug": slug }));
new_arr.push(ref_obj);
}
} else if let Some(o) = item.as_object_mut() {
if items.field_type == "referenceOrInline" {
if let Some(coll) = colls.first() {
o.insert("_type".to_string(), Value::String((*coll).to_string()));
}
}
new_arr.push(item);
} else {
new_arr.push(item);
}

View File

@@ -1,8 +1,9 @@
use std::sync::Arc;
use axum::routing::{get, post};
use axum::routing::{delete, get, post, put};
use axum::Router;
use super::assets;
use super::handlers;
use super::handlers::AppState;
use super::openapi;
@@ -12,6 +13,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
Router::new()
// Health (for Load Balancer / K8s)
.route("/health", get(handlers::health))
// Locales
.route("/api/locales", get(handlers::list_locales))
// API index (Living Documentation entry point)
.route("/api", get(openapi::api_index))
.route("/api/", get(openapi::api_index))
@@ -21,6 +24,10 @@ pub fn create_router(state: Arc<AppState>) -> Router {
// Collection schema endpoints
.route("/api/collections", get(handlers::list_collections))
.route("/api/schemas", post(handlers::create_schema))
.route(
"/api/schemas/:name",
put(handlers::update_schema).delete(handlers::delete_schema),
)
.route(
"/api/collections/:collection",
get(handlers::get_collection_schema),
@@ -42,5 +49,18 @@ pub fn create_router(state: Arc<AppState>) -> Router {
)
// Image transformation (external URL → transformed image)
.route("/api/transform", get(transform::transform_image))
// Asset management (images in content/assets/ with folder support)
.route("/api/assets", get(assets::list_assets).post(assets::upload_asset))
.route(
"/api/assets/folders",
get(assets::list_folders).post(assets::create_folder),
)
.route("/api/assets/folders/:name", delete(assets::delete_folder))
.route(
"/api/assets/*path",
get(assets::get_asset)
.patch(assets::rename_asset)
.delete(assets::delete_asset),
)
.with_state(state)
}

View File

@@ -13,6 +13,7 @@ use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat};
use serde::Deserialize;
use std::collections::hash_map::DefaultHasher;
use webpx::{Encoder as WebpEncoder, Unstoppable};
use super::error::ApiError;
use super::handlers::AppState;
@@ -43,7 +44,7 @@ pub struct TransformParams {
#[serde(default = "default_format")]
pub format: String,
/// JPEG quality 1100. Only for format=jpeg.
/// Output quality 1100. Used for JPEG and WebP (lossy). Default 85.
#[serde(default = "default_quality")]
pub quality: u8,
}
@@ -182,8 +183,11 @@ pub async fn transform_image(
(buf, "image/png")
}
"webp" => {
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP)
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
let buf = WebpEncoder::new_rgba(rgba.as_raw(), w, h)
.quality(quality as f32)
.encode(Unstoppable)
.map_err(|e| ApiError::Internal(format!("WebP encoding failed: {}", e)))?;
(buf, "image/webp")
}