Fixed media upload
Build and Publish / Build and Publish Docker Image (push) Successful in 1m39s

This commit is contained in:
ab
2026-07-11 15:20:21 +03:00
parent 1bd3e17672
commit 1bee7a7940
10 changed files with 146 additions and 36 deletions
Generated
+1 -1
View File
@@ -3466,7 +3466,7 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.15"
version = "1.0.0"
dependencies = [
"base64",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "1.0.0"
version = "1.0.1"
edition = "2024"
[dependencies]
+1
View File
@@ -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
+48 -19
View File
@@ -151,14 +151,14 @@ async fn save_uploaded_image(
data: &[u8],
) -> cot::Result<String> {
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()
}
}
}
+1
View File
@@ -6,6 +6,7 @@ mod public;
mod telegram;
mod turnstile;
mod tz;
mod uploads;
use tracing_subscriber;
+25 -4
View File
@@ -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()
}
}
}
+58
View File
@@ -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<Vec<u8>> {
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
}
+4 -4
View File
@@ -27,11 +27,11 @@
{% for item in &items %}
<div class="media-card">
{% if item.media.file_type == "photo" %}
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ item.media.id }}" alt="" loading="lazy">
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="photo">
<img src="/admin/uploads/{{ item.media.id.unwrap() }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ item.media.id }}" data-lightbox="video">
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
<div class="video-thumb">🎬</div>
</a>
{% endif %}
@@ -45,7 +45,7 @@
{% if let Some(cap) = item.media.caption.as_deref() %}
<div style="font-size:0.82rem;color:#666;margin-top:0.2rem;">{{ cap }}</div>
{% endif %}
<form method="post" action="/admin/media/{{ item.media.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
<form method="post" action="/admin/media/{{ item.media.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');" style="margin-top:0.3rem;">
<button class="button is-small is-danger is-outlined btn-sm">{{ t.media_delete }}</button>
</form>
</div>
+4 -4
View File
@@ -113,11 +113,11 @@
{% for m in &media %}
<div class="visit-media-item">
{% if m.file_type == "photo" %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="photo">
<img src="/admin/uploads/{{ m.id }}" alt="" loading="lazy">
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="photo">
<img src="/admin/uploads/{{ m.id.unwrap() }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/admin/uploads/{{ m.id }}" data-lightbox="video">
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
<div class="video-thumb-sm">🎬</div>
</a>
{% endif %}
@@ -137,7 +137,7 @@
<button type="submit" class="button is-primary is-fullwidth">{{ t.schedule_save }}</button>
</form>
{% for m in &media %}
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
<form id="visit-media-delete-{{ m.id.unwrap() }}" method="post" action="/admin/media/{{ m.id.unwrap() }}/delete" onsubmit="return confirm('{{ t.media_delete_confirm }}');"></form>
{% endfor %}
<hr style="margin:1rem 0;">
+3 -3
View File
@@ -147,11 +147,11 @@
<div class="media-row">
{% for m in &pv.media %}
{% if m.file_type == "photo" %}
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="photo">
<img src="/client/{{ client.media_token }}/media/{{ m.id }}" alt="" loading="lazy">
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="photo">
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" alt="" loading="lazy">
</a>
{% else %}
<a href="/client/{{ client.media_token }}/media/{{ m.id }}" data-lightbox="video">
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
<div class="vid-thumb">🎬</div>
</a>
{% endif %}