ograf_core/store/
graphics.rs1use std::{
2 path::{Path, PathBuf},
3 sync::OnceLock,
4 time::{Duration, Instant},
5};
6
7use chrono::{DateTime, Utc};
8use serde_json::Value;
9use tokio::sync::RwLock;
10
11use crate::{
12 error::{AppError, Result},
13 models::Graphic,
14};
15
16static GRAPHICS_CACHE: OnceLock<RwLock<GraphicsCache>> = OnceLock::new();
20
21struct GraphicsCache {
22 graphics: Vec<Graphic>,
23 fetched_at: Instant,
24}
25
26pub struct GraphicStore {
32 root: PathBuf,
33}
34
35pub fn is_valid_graphic_id(id: &str) -> bool {
40 !id.is_empty()
41 && id
42 .chars()
43 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
44}
45
46pub fn safe_join(base: &Path, rel: &str) -> Option<PathBuf> {
53 let mut result = base.to_path_buf();
54 for component in Path::new(rel).components() {
55 match component {
56 std::path::Component::Normal(part) => result.push(part),
57 std::path::Component::CurDir => {}
58 std::path::Component::ParentDir
59 | std::path::Component::RootDir
60 | std::path::Component::Prefix(_) => return None,
61 }
62 }
63 Some(result)
64}
65
66impl GraphicStore {
67 pub fn new(root: impl Into<PathBuf>) -> Self {
68 Self { root: root.into() }
69 }
70
71 pub async fn get(&self, id: &str) -> Result<Graphic> {
72 if !is_valid_graphic_id(id) {
73 return Err(AppError::NotFound(format!("graphic '{id}'")));
74 }
75 load_one(&self.root, id)
76 .await
77 .ok_or_else(|| AppError::NotFound(format!("graphic '{id}'")))
78 }
79
80 pub async fn list(&self) -> Result<Vec<Graphic>> {
81 let mut entries = match tokio::fs::read_dir(&self.root).await {
82 Ok(entries) => entries,
83 Err(_) => return Ok(Vec::new()),
86 };
87
88 let mut graphics = Vec::new();
89 while let Some(entry) = entries
90 .next_entry()
91 .await
92 .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to read graphics dir: {e}")))?
93 {
94 if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
95 continue;
96 }
97 let id = entry.file_name().to_string_lossy().into_owned();
98 if let Some(graphic) = load_one(&self.root, &id).await {
102 graphics.push(graphic);
103 }
104 }
105
106 graphics.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at));
107 Ok(graphics)
108 }
109
110 pub async fn list_cached(&self, ttl: Duration) -> Result<Vec<Graphic>> {
114 if ttl.is_zero() {
116 return self.list().await;
117 }
118
119 let cache = GRAPHICS_CACHE.get_or_init(|| {
120 RwLock::new(GraphicsCache {
121 graphics: Vec::new(),
122 fetched_at: Instant::now() - Duration::from_secs(3600),
124 })
125 });
126
127 {
129 let guard = cache.read().await;
130 if guard.fetched_at.elapsed() < ttl {
131 return Ok(guard.graphics.clone());
132 }
133 }
134
135 let mut guard = cache.write().await;
137
138 if guard.fetched_at.elapsed() < ttl {
140 return Ok(guard.graphics.clone());
141 }
142
143 let graphics = self.list().await?;
145 guard.graphics = graphics.clone();
146 guard.fetched_at = Instant::now();
147
148 Ok(graphics)
149 }
150
151 pub fn path_for(&self, id: &str) -> PathBuf {
154 self.root.join(id)
155 }
156}
157
158async fn load_one(root: &Path, id: &str) -> Option<Graphic> {
159 let dir = root.join(id);
160 let manifest_path = find_manifest(&dir).await?;
161
162 let raw = tokio::fs::read_to_string(&manifest_path).await.ok()?;
163 let manifest: Value = serde_json::from_str(&raw).ok()?;
164
165 let uploaded_at = tokio::fs::metadata(&manifest_path)
166 .await
167 .ok()
168 .and_then(|m| m.modified().ok())
169 .map(|t| DateTime::<Utc>::from(t).to_rfc3339())
170 .unwrap_or_default();
171
172 Some(Graphic {
173 id: id.to_string(),
174 name: manifest["name"].as_str().unwrap_or(id).to_string(),
175 version: manifest["version"].as_str().map(String::from),
176 description: manifest["description"].as_str().map(String::from),
177 manifest,
178 storage_path: dir.to_string_lossy().into_owned(),
179 uploaded_at,
180 })
181}
182
183async fn find_manifest(dir: &Path) -> Option<PathBuf> {
184 let mut entries = tokio::fs::read_dir(dir).await.ok()?;
185 while let Some(entry) = entries.next_entry().await.ok()? {
186 let name = entry.file_name().to_string_lossy().into_owned();
187 if name.ends_with(".ograf.json") {
188 return Some(entry.path());
189 }
190 }
191 None
192}