Skip to main content

sl_map_web/
storage.rs

1//! On-disk storage of saved render image bytes.
2//!
3//! Saved renders are stored as files under `<storage_dir>/renders/`. Each
4//! file is named by the render's UUID plus a suffix that distinguishes the
5//! primary image from the optional without-route variant. The DB row carries
6//! just the relative filename so the actual storage location can change
7//! without a schema migration.
8
9use std::path::{Path, PathBuf};
10
11use bytes::Bytes;
12use uuid::Uuid;
13
14use crate::error::Error;
15
16/// Subdirectory under `storage_dir` where render image files live.
17const RENDERS_SUBDIR: &str = "renders";
18
19/// Subdirectory under `storage_dir` where uploaded logo image files live.
20const LOGOS_SUBDIR: &str = "logos";
21
22/// Suffix used for the primary rendered image file.
23pub const IMAGE_SUFFIX: &str = "image";
24
25/// Suffix used for the optional "without route" variant of a render.
26pub const IMAGE_WITHOUT_ROUTE_SUFFIX: &str = "image-without-route";
27
28/// Map an `image/...` MIME type to the file extension that should be used on
29/// disk. Returns `bin` for anything unrecognised so the file is still
30/// preserved and can be inspected manually.
31#[must_use]
32pub fn ext_for_content_type(content_type: &str) -> &'static str {
33    match content_type {
34        "image/png" => "png",
35        "image/jpeg" => "jpg",
36        "image/webp" => "webp",
37        _ => "bin",
38    }
39}
40
41/// Compute the relative filename (relative to `<storage_dir>/renders/`) for a
42/// render's image file given the render id, suffix, and extension.
43#[must_use]
44pub fn render_filename(render_id: Uuid, suffix: &str, ext: &str) -> String {
45    format!("{render_id}-{suffix}.{ext}")
46}
47
48/// Compute the absolute path to a render file.
49#[must_use]
50pub fn render_path(storage_dir: &Path, filename: &str) -> PathBuf {
51    storage_dir.join(RENDERS_SUBDIR).join(filename)
52}
53
54/// Create the on-startup subdirectory layout under `storage_dir`.
55///
56/// # Errors
57///
58/// Returns an [`Error::Io`] if the directories cannot be created.
59pub fn ensure_layout(storage_dir: &Path) -> Result<(), Error> {
60    fs_err::create_dir_all(storage_dir.join(RENDERS_SUBDIR))?;
61    fs_err::create_dir_all(storage_dir.join(LOGOS_SUBDIR))?;
62    Ok(())
63}
64
65/// Write the rendered image bytes for a render to disk, returning the
66/// filename that should be stored in the DB.
67///
68/// # Errors
69///
70/// Returns an [`Error::Io`] if the file cannot be written.
71pub async fn write_render_file(
72    storage_dir: &Path,
73    render_id: Uuid,
74    suffix: &str,
75    ext: &str,
76    bytes: Bytes,
77) -> Result<String, Error> {
78    let filename = render_filename(render_id, suffix, ext);
79    let path = render_path(storage_dir, &filename);
80    tokio::task::spawn_blocking(move || fs_err::write(&path, &bytes))
81        .await
82        .map_err(|err| Error::Io(std::io::Error::other(err)))??;
83    Ok(filename)
84}
85
86/// Read the rendered image bytes back from disk.
87///
88/// # Errors
89///
90/// Returns [`Error::NotFound`] if the file does not exist, or [`Error::Io`]
91/// for any other I/O failure.
92pub async fn read_render_file(storage_dir: &Path, filename: &str) -> Result<Bytes, Error> {
93    let path = render_path(storage_dir, filename);
94    let filename_for_err = filename.to_owned();
95    let bytes = tokio::task::spawn_blocking(move || fs_err::read(&path))
96        .await
97        .map_err(|err| Error::Io(std::io::Error::other(err)))?;
98    match bytes {
99        Ok(b) => Ok(Bytes::from(b)),
100        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
101            Err(Error::NotFound(format!("render file `{filename_for_err}`")))
102        }
103        Err(err) => Err(Error::Io(err)),
104    }
105}
106
107/// Best-effort delete of a single render file. A missing file is treated as
108/// success (the row may have been deleted already). Other errors are
109/// returned so the caller can decide whether to surface them or raise the
110/// sweeper "dirty" flag for a later retry.
111///
112/// # Errors
113///
114/// Returns [`Error::Io`] for I/O failures other than `NotFound`.
115pub fn try_delete_render_file(storage_dir: &Path, filename: &str) -> Result<(), Error> {
116    let path = render_path(storage_dir, filename);
117    match fs_err::remove_file(&path) {
118        Ok(()) => Ok(()),
119        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
120        Err(err) => Err(Error::Io(err)),
121    }
122}
123
124/// List the filenames currently present under the `renders/` subdirectory.
125/// Used by the orphan sweeper.
126///
127/// # Errors
128///
129/// Returns [`Error::Io`] if the directory cannot be read.
130pub fn list_render_files(storage_dir: &Path) -> Result<Vec<String>, Error> {
131    let dir = storage_dir.join(RENDERS_SUBDIR);
132    let mut out = Vec::new();
133    let entries = fs_err::read_dir(&dir)?;
134    for entry in entries {
135        let entry = entry?;
136        if !entry.file_type()?.is_file() {
137            continue;
138        }
139        if let Some(name) = entry.file_name().to_str() {
140            out.push(name.to_owned());
141        }
142    }
143    Ok(out)
144}
145
146/// Extract the render UUID prefix from a filename produced by
147/// [`render_filename`]. Returns `None` if the filename does not start with a
148/// valid 36-character hyphenated UUID. Filenames are of the form
149/// `{render_id}-{suffix}.{ext}`, where `render_id` itself contains four
150/// hyphens, so we cannot just split on `-`.
151#[must_use]
152pub fn parse_render_id_from_filename(filename: &str) -> Option<Uuid> {
153    // 36 = length of a hyphenated UUID, e.g. `00000000-0000-0000-0000-000000000000`.
154    const UUID_LEN: usize = 36;
155    let prefix: String = filename.chars().take(UUID_LEN).collect();
156    if prefix.chars().count() != UUID_LEN {
157        return None;
158    }
159    Uuid::parse_str(&prefix).ok()
160}
161
162// ---------------------------------------------------------------------
163// Uploaded logo images (saved_logos).
164//
165// Mirrors the render-file helpers above but lives under `logos/`. A logo
166// file is named `{logo_id}.{ext}` — there is only ever one variant per
167// logo, so (unlike renders) there is no suffix segment.
168// ---------------------------------------------------------------------
169
170/// Compute the relative filename (relative to `<storage_dir>/logos/`) for a
171/// logo's image file given the logo id and extension.
172#[must_use]
173pub fn logo_filename(logo_id: Uuid, ext: &str) -> String {
174    format!("{logo_id}.{ext}")
175}
176
177/// Compute the absolute path to a logo file.
178#[must_use]
179pub fn logo_path(storage_dir: &Path, filename: &str) -> PathBuf {
180    storage_dir.join(LOGOS_SUBDIR).join(filename)
181}
182
183/// Write a logo's image bytes to disk, returning the filename that should
184/// be stored in the DB.
185///
186/// # Errors
187///
188/// Returns an [`Error::Io`] if the file cannot be written.
189pub async fn write_logo_file(
190    storage_dir: &Path,
191    logo_id: Uuid,
192    ext: &str,
193    bytes: Bytes,
194) -> Result<String, Error> {
195    let filename = logo_filename(logo_id, ext);
196    let path = logo_path(storage_dir, &filename);
197    tokio::task::spawn_blocking(move || fs_err::write(&path, &bytes))
198        .await
199        .map_err(|err| Error::Io(std::io::Error::other(err)))??;
200    Ok(filename)
201}
202
203/// Read a logo's image bytes back from disk.
204///
205/// # Errors
206///
207/// Returns [`Error::NotFound`] if the file does not exist, or [`Error::Io`]
208/// for any other I/O failure.
209pub async fn read_logo_file(storage_dir: &Path, filename: &str) -> Result<Bytes, Error> {
210    let path = logo_path(storage_dir, filename);
211    let filename_for_err = filename.to_owned();
212    let bytes = tokio::task::spawn_blocking(move || fs_err::read(&path))
213        .await
214        .map_err(|err| Error::Io(std::io::Error::other(err)))?;
215    match bytes {
216        Ok(b) => Ok(Bytes::from(b)),
217        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
218            Err(Error::NotFound(format!("logo file `{filename_for_err}`")))
219        }
220        Err(err) => Err(Error::Io(err)),
221    }
222}
223
224/// Best-effort delete of a single logo file. A missing file is treated as
225/// success. See [`try_delete_render_file`] for the rationale.
226///
227/// # Errors
228///
229/// Returns [`Error::Io`] for I/O failures other than `NotFound`.
230pub fn try_delete_logo_file(storage_dir: &Path, filename: &str) -> Result<(), Error> {
231    let path = logo_path(storage_dir, filename);
232    match fs_err::remove_file(&path) {
233        Ok(()) => Ok(()),
234        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
235        Err(err) => Err(Error::Io(err)),
236    }
237}
238
239/// List the filenames currently present under the `logos/` subdirectory.
240/// Used by the orphan sweeper.
241///
242/// # Errors
243///
244/// Returns [`Error::Io`] if the directory cannot be read.
245pub fn list_logo_files(storage_dir: &Path) -> Result<Vec<String>, Error> {
246    let dir = storage_dir.join(LOGOS_SUBDIR);
247    let mut out = Vec::new();
248    let entries = fs_err::read_dir(&dir)?;
249    for entry in entries {
250        let entry = entry?;
251        if !entry.file_type()?.is_file() {
252            continue;
253        }
254        if let Some(name) = entry.file_name().to_str() {
255            out.push(name.to_owned());
256        }
257    }
258    Ok(out)
259}
260
261/// Extract the logo UUID prefix from a filename produced by
262/// [`logo_filename`]. Returns `None` if the filename does not start with a
263/// valid 36-character hyphenated UUID.
264#[must_use]
265pub fn parse_logo_id_from_filename(filename: &str) -> Option<Uuid> {
266    // 36 = length of a hyphenated UUID.
267    const UUID_LEN: usize = 36;
268    let prefix: String = filename.chars().take(UUID_LEN).collect();
269    if prefix.chars().count() != UUID_LEN {
270        return None;
271    }
272    Uuid::parse_str(&prefix).ok()
273}