use std::{
path::{Path, PathBuf},
sync::OnceLock,
time::{Duration, Instant},
};
use chrono::{DateTime, Utc};
use serde_json::Value;
use tokio::sync::RwLock;
use crate::{
error::{AppError, Result},
models::Graphic,
};
static GRAPHICS_CACHE: OnceLock<RwLock<GraphicsCache>> = OnceLock::new();
struct GraphicsCache {
graphics: Vec<Graphic>,
fetched_at: Instant,
}
pub struct GraphicStore {
root: PathBuf,
}
pub fn is_valid_graphic_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
let mut result = base.to_path_buf();
for component in Path::new(rel).components() {
match component {
std::path::Component::Normal(part) => result.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_) => return None,
}
}
Some(result)
}
impl GraphicStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub async fn get(&self, id: &str) -> Result<Graphic> {
if !is_valid_graphic_id(id) {
return Err(AppError::NotFound(format!("graphic '{id}'")));
}
load_one(&self.root, id)
.await
.ok_or_else(|| AppError::NotFound(format!("graphic '{id}'")))
}
pub async fn list(&self) -> Result<Vec<Graphic>> {
let mut entries = match tokio::fs::read_dir(&self.root).await {
Ok(entries) => entries,
Err(_) => return Ok(Vec::new()),
};
let mut graphics = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("failed to read graphics dir: {e}")))?
{
if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let id = entry.file_name().to_string_lossy().into_owned();
if let Some(graphic) = load_one(&self.root, &id).await {
graphics.push(graphic);
}
}
graphics.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
Ok(graphics)
}
pub async fn list_cached(&self, ttl: Duration) -> Result<Vec<Graphic>> {
if ttl.is_zero() {
return self.list().await;
}
let cache = GRAPHICS_CACHE.get_or_init(|| {
RwLock::new(GraphicsCache {
graphics: Vec::new(),
fetched_at: Instant::now() - Duration::from_secs(3600),
})
});
{
let guard = cache.read().await;
if guard.fetched_at.elapsed() < ttl {
return Ok(guard.graphics.clone());
}
}
let mut guard = cache.write().await;
if guard.fetched_at.elapsed() < ttl {
return Ok(guard.graphics.clone());
}
let graphics = self.list().await?;
guard.graphics = graphics.clone();
guard.fetched_at = Instant::now();
Ok(graphics)
}
pub fn path_for(&self, id: &str) -> PathBuf {
self.root.join(id)
}
}
async fn load_one(root: &Path, id: &str) -> Option<Graphic> {
let dir = root.join(id);
let manifest_path = find_manifest(&dir).await?;
let raw = tokio::fs::read_to_string(&manifest_path).await.ok()?;
let manifest: Value = serde_json::from_str(&raw).ok()?;
let uploaded_at = tokio::fs::metadata(&manifest_path)
.await
.ok()
.and_then(|m| m.modified().ok())
.map(|t| DateTime::<Utc>::from(t).to_rfc3339())
.unwrap_or_default();
Some(Graphic {
id: id.to_string(),
name: manifest["name"].as_str().unwrap_or(id).to_string(),
version: manifest["version"].as_str().map(String::from),
description: manifest["description"].as_str().map(String::from),
manifest,
storage_path: dir.to_string_lossy().into_owned(),
uploaded_at,
})
}
async fn find_manifest(dir: &Path) -> Option<PathBuf> {
let mut entries = tokio::fs::read_dir(dir).await.ok()?;
while let Some(entry) = entries.next_entry().await.ok()? {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".ograf.json") {
return Some(entry.path());
}
}
None
}