Skip to main content

ograf_core/store/
graphics.rs

1use 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
16/// Global cache for graphics list. Shared across all GraphicStore instances
17/// to avoid redundant disk scans when multiple handlers request the list
18/// concurrently or in quick succession.
19static GRAPHICS_CACHE: OnceLock<RwLock<GraphicsCache>> = OnceLock::new();
20
21struct GraphicsCache {
22    graphics: Vec<Graphic>,
23    fetched_at: Instant,
24}
25
26/// Reads graphics straight from disk — Core owns no database. Whatever sits
27/// in front of Core (admin routes, or a human with `scp`) writes (and deletes)
28/// `{graphics_storage}/{graphic_id}/...` directly; Core only ever reads.
29/// Caches the list() result for a configurable TTL to avoid repeated disk
30/// scans when serving multiple concurrent requests.
31pub struct GraphicStore {
32    root: PathBuf,
33}
34
35/// A `graphic_id` reaches here straight from a URL path segment — it's only
36/// ever safe to use as a filesystem directory name (never joined containing
37/// `..`/`/`) once it passes this. Enforced here since every other caller
38/// (read/thumbnail/asset/delete) trusts whatever's in the URL.
39pub 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
46/// Joins `rel` onto `base` component-by-component, rejecting `..` and
47/// absolute paths instead of letting them either escape `base` or (per
48/// `PathBuf::join`'s documented behavior) discard `base` entirely when `rel`
49/// is itself absolute. `rel` here is untrusted (a query param / URL
50/// wildcard tail) and used to serve files straight off disk, so this is the
51/// only thing standing between a request and arbitrary file read.
52pub 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            // No graphics have ever been uploaded yet — an empty list, not
84            // an error.
85            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            // A directory without a valid manifest isn't a graphic (could be
99            // mid-upload, or leftover cruft) — skip it rather than fail the
100            // whole listing.
101            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    /// Like `list()`, but caches the result for `ttl`. If `ttl` is zero,
111    /// behaves identically to `list()` (always fetches fresh from disk).
112    /// The cache is global and shared across all GraphicStore instances.
113    pub async fn list_cached(&self, ttl: Duration) -> Result<Vec<Graphic>> {
114        // TTL of zero means no caching — always fetch fresh
115        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                // Force initial fetch by setting timestamp in the past
123                fetched_at: Instant::now() - Duration::from_secs(3600),
124            })
125        });
126
127        // Fast path: check if cache is still valid under read lock
128        {
129            let guard = cache.read().await;
130            if guard.fetched_at.elapsed() < ttl {
131                return Ok(guard.graphics.clone());
132            }
133        }
134
135        // Slow path: cache expired, acquire write lock and refresh
136        let mut guard = cache.write().await;
137
138        // Double-check: another task might have refreshed while we waited
139        if guard.fetched_at.elapsed() < ttl {
140            return Ok(guard.graphics.clone());
141        }
142
143        // Actually fetch from disk
144        let graphics = self.list().await?;
145        guard.graphics = graphics.clone();
146        guard.fetched_at = Instant::now();
147
148        Ok(graphics)
149    }
150
151    /// Storage path for a graphic that may or may not exist — used by asset
152    /// and thumbnail serving, which do their own 404 handling on read.
153    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}