diff --git a/Cargo.lock b/Cargo.lock index 659d536..fc4b4a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3466,7 +3466,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "0.1.15" +version = "1.0.0" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f327a6b..7ff6c1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "1.0.0" +version = "1.0.1" edition = "2024" [dependencies] diff --git a/Dockerfile b/Dockerfile index 95c9bf4..96e9ef4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN cargo build --release FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /data +ENV WEB_PETTING_UPLOAD_DIR=/data/uploads COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting COPY static /app/static EXPOSE 3000 diff --git a/src/admin.rs b/src/admin.rs index cbbf0eb..c3f3d96 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -151,14 +151,14 @@ async fn save_uploaded_image( data: &[u8], ) -> cot::Result { if let Some(encoded) = transcode_uploaded_image(data, ext)? { - let path = format!("{}/{}.jpg", upload_dir, file_id); - tokio::fs::write(&path, &encoded) + let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg")); + crate::uploads::write_db_file(&path, &encoded) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(path) } else { - let path = format!("{}/{}.{}", upload_dir, file_id, ext); - tokio::fs::write(&path, data) + let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}")); + crate::uploads::write_db_file(&path, data) .await .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(path) @@ -2140,8 +2140,8 @@ async fn media_upload_submit( futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); let mut multipart = multer::Multipart::new(stream, boundary); - let upload_dir = format!("uploads/{}/{}", client_id, visit_id); - tokio::fs::create_dir_all(&upload_dir) + let upload_dir = crate::uploads::media_dir(client_id, visit_id); + crate::uploads::create_logical_dir(&upload_dir) .await .map_err(|e| cot::Error::internal(e.to_string()))?; @@ -2193,8 +2193,8 @@ async fn media_upload_submit( let file_path = if file_type == "photo" { save_uploaded_image(&upload_dir, file_id, &ext, &data).await? } else { - let path = format!("{}/{}.{}", upload_dir, file_id, ext); - tokio::fs::write(&path, &data) + let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}")); + crate::uploads::write_db_file(&path, &data) .await .map_err(|e| cot::Error::internal(e.to_string()))?; path @@ -2251,7 +2251,16 @@ async fn media_delete( let file_path = m.file_path.clone(); m.status = "archived".to_string(); m.save(&db).await?; - let _ = tokio::fs::remove_file(&file_path).await; + if let Err(err) = crate::uploads::remove_db_file(&file_path).await { + tracing::warn!( + target: "uploads", + media_id, + db_path = %file_path, + resolved_path = %crate::uploads::resolved_display_path(&file_path), + error = %err, + "failed to remove uploaded file" + ); + } } let redirect_url = referer .filter(|r| r.contains("/schedule/") && r.contains("/edit")) @@ -2276,7 +2285,7 @@ async fn serve_upload( None => return Html::new("404").into_response(), }; - match tokio::fs::read(&media.file_path).await { + match crate::uploads::read_db_file(&media.file_path).await { Ok(data) => { let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", @@ -2296,7 +2305,17 @@ async fn serve_upload( .insert("content-type", content_type.parse().unwrap()); Ok(resp) } - Err(_) => Html::new("404").into_response(), + Err(err) => { + tracing::warn!( + target: "uploads", + media_id, + db_path = %media.file_path, + resolved_path = %crate::uploads::resolved_display_path(&media.file_path), + error = %err, + "uploaded file is missing or unreadable" + ); + Html::new("404").into_response() + } } } @@ -2403,12 +2422,12 @@ async fn testimonial_add( if data.is_empty() { continue; } - let upload_dir = "uploads/testimonials"; - tokio::fs::create_dir_all(upload_dir) + let upload_dir = crate::uploads::testimonials_dir(); + crate::uploads::create_logical_dir(&upload_dir) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let file_id = uuid::Uuid::new_v4(); - let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?; + let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?; image_path = Some(path); } _ => {} @@ -2551,12 +2570,12 @@ async fn testimonial_edit( if data.is_empty() { continue; } - let upload_dir = "uploads/testimonials"; - tokio::fs::create_dir_all(upload_dir) + let upload_dir = crate::uploads::testimonials_dir(); + crate::uploads::create_logical_dir(&upload_dir) .await .map_err(|e| cot::Error::internal(e.to_string()))?; let file_id = uuid::Uuid::new_v4(); - let path = save_uploaded_image(upload_dir, file_id, &ext, &data).await?; + let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?; new_image_path = Some(path); } _ => {} @@ -2597,7 +2616,7 @@ async fn serve_testimonial_image( Some(p) => p.clone(), None => return Html::new("404").into_response(), }; - match tokio::fs::read(&path).await { + match crate::uploads::read_db_file(&path).await { Ok(data) => { let content_type = match path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", @@ -2612,7 +2631,17 @@ async fn serve_testimonial_image( .insert("content-type", content_type.parse().unwrap()); Ok(resp) } - Err(_) => Html::new("404").into_response(), + Err(err) => { + tracing::warn!( + target: "uploads", + testimonial_id = id, + db_path = %path, + resolved_path = %crate::uploads::resolved_display_path(&path), + error = %err, + "testimonial image is missing or unreadable" + ); + Html::new("404").into_response() + } } } diff --git a/src/main.rs b/src/main.rs index e03ea27..98e4708 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod public; mod telegram; mod turnstile; mod tz; +mod uploads; use tracing_subscriber; diff --git a/src/public.rs b/src/public.rs index 46e54c0..73a02fa 100644 --- a/src/public.rs +++ b/src/public.rs @@ -276,6 +276,7 @@ async fn client_portal( .iter() .filter(|m| { m.status == "active" + && m.client_id.primary_key().unwrap() == client_id && m.visit_id .as_ref() .map(|fk| fk.primary_key().unwrap() == vid) @@ -393,7 +394,7 @@ async fn portal_media( } } - match tokio::fs::read(&media.file_path).await { + match crate::uploads::read_db_file(&media.file_path).await { Ok(data) => { let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", @@ -413,7 +414,17 @@ async fn portal_media( .insert("content-type", content_type.parse().unwrap()); Ok(resp) } - Err(_) => Html::new("404").into_response(), + Err(err) => { + tracing::warn!( + target: "uploads", + media_id, + db_path = %media.file_path, + resolved_path = %crate::uploads::resolved_display_path(&media.file_path), + error = %err, + "portal media file is missing or unreadable" + ); + Html::new("404").into_response() + } } } @@ -430,7 +441,7 @@ async fn serve_testimonial_image( Some(p) => p.clone(), None => return Html::new("404").into_response(), }; - match tokio::fs::read(&path).await { + match crate::uploads::read_db_file(&path).await { Ok(data) => { let content_type = match path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", @@ -447,7 +458,17 @@ async fn serve_testimonial_image( .insert("cache-control", "public, max-age=86400".parse().unwrap()); Ok(resp) } - Err(_) => Html::new("404").into_response(), + Err(err) => { + tracing::warn!( + target: "uploads", + testimonial_id = id, + db_path = %path, + resolved_path = %crate::uploads::resolved_display_path(&path), + error = %err, + "testimonial image is missing or unreadable" + ); + Html::new("404").into_response() + } } } diff --git a/src/uploads.rs b/src/uploads.rs new file mode 100644 index 0000000..cddb65d --- /dev/null +++ b/src/uploads.rs @@ -0,0 +1,58 @@ +use std::path::{Path, PathBuf}; + +const DEFAULT_UPLOAD_DIR: &str = "uploads"; +const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR"; + +pub fn media_dir(client_id: i64, visit_id: i64) -> String { + format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}") +} + +pub fn testimonials_dir() -> String { + format!("{DEFAULT_UPLOAD_DIR}/testimonials") +} + +pub fn join_db_path(dir: &str, filename: &str) -> String { + format!("{}/{}", dir.trim_end_matches('/'), filename) +} + +pub fn resolve_db_path(db_path: &str) -> PathBuf { + let path = PathBuf::from(db_path); + if path.is_absolute() { + return path; + } + + let Some(upload_root) = std::env::var_os(UPLOAD_DIR_ENV) else { + return path; + }; + + let upload_root = PathBuf::from(upload_root); + let logical_path = Path::new(db_path); + match logical_path.strip_prefix(DEFAULT_UPLOAD_DIR) { + Ok(stripped) => upload_root.join(stripped), + Err(_) => upload_root.join(logical_path), + } +} + +pub fn resolved_display_path(db_path: &str) -> String { + resolve_db_path(db_path).display().to_string() +} + +pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> { + tokio::fs::create_dir_all(resolve_db_path(db_dir)).await +} + +pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> { + let physical_path = resolve_db_path(db_path); + if let Some(parent) = physical_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(physical_path, data).await +} + +pub async fn read_db_file(db_path: &str) -> std::io::Result> { + tokio::fs::read(resolve_db_path(db_path)).await +} + +pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> { + tokio::fs::remove_file(resolve_db_path(db_path)).await +} diff --git a/templates/admin/media.html b/templates/admin/media.html index 952a1d7..fd72368 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -27,11 +27,11 @@ {% for item in &items %}
{% if item.media.file_type == "photo" %} - - + + {% else %} - +
🎬
{% endif %} @@ -45,7 +45,7 @@ {% if let Some(cap) = item.media.caption.as_deref() %}
{{ cap }}
{% endif %} -
+
diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index 511485b..057da29 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -113,11 +113,11 @@ {% for m in &media %}
{% if m.file_type == "photo" %} - - + + {% else %} - +
🎬
{% endif %} @@ -137,7 +137,7 @@ {% for m in &media %} -
+
{% endfor %}
diff --git a/templates/client_portal.html b/templates/client_portal.html index 4746eae..e4a4ea3 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -147,11 +147,11 @@
{% for m in &pv.media %} {% if m.file_type == "photo" %} - - + + {% else %} - +
🎬
{% endif %}