Enhance RustyCMS: Update .gitignore to include demo assets, improve admin UI dependency management, and add new translations for asset management. Implement asset date filtering and enhance content forms with asset previews. Introduce caching mechanisms for improved performance and add support for draft status in content entries.

This commit is contained in:
Peter Meier
2026-03-12 16:03:26 +01:00
parent 7795a238e1
commit 22b4367c47
24 changed files with 900 additions and 131 deletions

View File

@@ -13,9 +13,11 @@
//! DELETE /api/assets/*path delete image (auth required)
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use axum::body::Body;
use axum::extract::{Multipart, Path as AxumPath, Query, State};
use chrono::{DateTime, Utc};
use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
@@ -131,18 +133,33 @@ async fn read_images(
if mime_for_ext(&ext).is_none() {
continue;
}
let size = e.metadata().await.map(|m| m.len()).unwrap_or(0);
let meta = e.metadata().await.map_err(|e| ApiError::Internal(e.to_string()))?;
let size = meta.len();
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),
};
let created_at = meta
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
entries.push(json!({
"filename": fname,
"folder": folder,
"url": url,
"mime_type": mime,
"size": size,
"created_at": created_at,
"modified_at": modified_at,
}));
}
Ok(entries)
@@ -340,6 +357,13 @@ pub struct UploadParams {
folder: Option<String>,
}
/// Max upload size for asset uploads (50 MB).
const MAX_UPLOAD_SIZE: usize = 50 * 1024 * 1024;
pub fn upload_body_limit() -> DefaultBodyLimit {
DefaultBodyLimit::max(MAX_UPLOAD_SIZE)
}
pub async fn upload_asset(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -409,6 +433,19 @@ pub async fn upload_asset(
Some(f) => format!("/api/assets/{}/{}", f, filename),
None => format!("/api/assets/{}", filename),
};
let file_meta = fs::metadata(&dest).await.ok();
let created_at = file_meta
.as_ref()
.and_then(|m| m.created().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = file_meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
Ok((
StatusCode::CREATED,
@@ -418,6 +455,8 @@ pub async fn upload_asset(
"url": url,
"mime_type": mime,
"size": data.len(),
"created_at": created_at,
"modified_at": modified_at,
})),
)
.into_response())
@@ -504,6 +543,18 @@ pub async fn rename_asset(
let meta = fs::metadata(&new_path)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let created_at = meta
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
let modified_at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
Ok((
StatusCode::OK,
Json(json!({
@@ -512,6 +563,8 @@ pub async fn rename_asset(
"url": url,
"mime_type": mime,
"size": meta.len(),
"created_at": created_at,
"modified_at": modified_at,
})),
)
.into_response())

View File

@@ -65,6 +65,12 @@ impl ContentCache {
let mut guard = self.data.write().await;
guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l));
}
/// Clears the entire cache (e.g. after schema hot-reload so content is re-read with new schema).
pub async fn invalidate_all(&self) {
let mut guard = self.data.write().await;
guard.clear();
}
}
/// Cache key for a single entry (incl. _resolve and optional _locale).

View File

@@ -14,7 +14,7 @@ use tokio::sync::RwLock;
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
use crate::schema::validator;
use crate::schema::SchemaRegistry;
use crate::store::query::QueryParams;
use crate::store::query::{QueryParams, StatusFilter, entry_is_draft};
use crate::store::ContentStore;
use crate::store::slug;
@@ -368,6 +368,7 @@ pub async fn list_entries(
State(state): State<Arc<AppState>>,
Path(collection): Path<String>,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let registry = state.registry.read().await;
let schema = registry
@@ -411,7 +412,15 @@ pub async fn list_entries(
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
let query = QueryParams::from_map(list_params);
// When API key is required but not sent, show only published entries. Otherwise use _status param.
let status_override = if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
{
Some(StatusFilter::Published)
} else {
None
};
let query = QueryParams::from_map_with_status(list_params, status_override);
let mut result = query.apply(entries);
for item in result.items.iter_mut() {
@@ -461,29 +470,36 @@ pub async fn get_entry(
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
if let Some(ref cached) = state.cache.get(&cache_key).await {
let json_str = serde_json::to_string(cached).unwrap_or_default();
let mut hasher = DefaultHasher::new();
json_str.hash(&mut hasher);
let etag_plain = format!("\"{:016x}\"", hasher.finish());
let etag_header =
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
let if_none_match = headers
.get("if-none-match")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().trim_matches('"').to_string());
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
// Don't serve cached draft to unauthenticated requests.
let is_authenticated = state.api_key.as_ref().is_none()
|| auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str);
if !is_authenticated && entry_is_draft(cached) {
// Fall through to load from store (will 404 if draft).
} else {
let json_str = serde_json::to_string(cached).unwrap_or_default();
let mut hasher = DefaultHasher::new();
json_str.hash(&mut hasher);
let etag_plain = format!("\"{:016x}\"", hasher.finish());
let etag_header =
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
let if_none_match = headers
.get("if-none-match")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().trim_matches('"').to_string());
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
return Ok((
StatusCode::NOT_MODIFIED,
[("ETag", etag_header.clone())],
)
.into_response());
}
return Ok((
StatusCode::NOT_MODIFIED,
[("ETag", etag_header.clone())],
StatusCode::OK,
[("ETag", etag_header)],
Json(cached.clone()),
)
.into_response());
}
return Ok((
StatusCode::OK,
[("ETag", etag_header)],
Json(cached.clone()),
)
.into_response());
}
let entry = state
@@ -495,6 +511,17 @@ pub async fn get_entry(
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
})?;
// When no API key is sent, hide draft entries (return 404).
if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
&& entry_is_draft(&entry)
{
return Err(ApiError::NotFound(format!(
"Entry '{}' not found in '{}'",
slug, collection
)));
}
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
let mut formatted = format_references(
entry,
@@ -509,10 +536,10 @@ pub async fn get_entry(
expand_asset_urls(&mut formatted, &state.base_url);
}
state
.cache
.set(cache_key, formatted.clone())
.await;
// Only cache published entries so unauthenticated requests never see cached drafts.
if !entry_is_draft(&formatted) {
state.cache.set(cache_key, formatted.clone()).await;
}
let json_str = serde_json::to_string(&formatted).unwrap_or_default();
let mut hasher = DefaultHasher::new();
@@ -581,9 +608,19 @@ pub async fn create_entry(
slug::validate_slug(&slug_raw).map_err(ApiError::BadRequest)?;
let slug = slug::normalize_slug(&slug_raw);
// Remove _slug from content data
// Remove _slug from content data; validate and default _status (publish/draft)
if let Some(obj) = body.as_object_mut() {
obj.remove("_slug");
if let Some(v) = obj.get("_status") {
let s = v.as_str().unwrap_or("");
if s != "draft" && s != "published" {
return Err(ApiError::BadRequest(
"Field '_status' must be \"draft\" or \"published\"".to_string(),
));
}
} else {
obj.insert("_status".to_string(), Value::String("published".to_string()));
}
}
// Apply defaults and auto-generated values
@@ -639,7 +676,7 @@ pub async fn create_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let mut formatted = format_references(
let formatted = format_references(
entry,
&schema,
state.store.as_ref(),
@@ -682,9 +719,17 @@ pub async fn update_entry(
)));
}
// Remove _slug if present in body
// Remove _slug if present; validate _status when present
if let Some(obj) = body.as_object_mut() {
obj.remove("_slug");
if let Some(v) = obj.get("_status") {
let s = v.as_str().unwrap_or("");
if s != "draft" && s != "published" {
return Err(ApiError::BadRequest(
"Field '_status' must be \"draft\" or \"published\"".to_string(),
));
}
}
}
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
@@ -754,7 +799,7 @@ pub async fn update_entry(
.await
.map_err(ApiError::from)?
.unwrap();
let mut formatted = format_references(
let formatted = format_references(
entry,
&schema,
state.store.as_ref(),

View File

@@ -50,7 +50,12 @@ 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",
get(assets::list_assets)
.post(assets::upload_asset)
.layer(assets::upload_body_limit()),
)
.route(
"/api/assets/folders",
get(assets::list_folders).post(assets::create_folder),