Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s

This commit is contained in:
Aleksandr Bogomiakov
2026-08-09 00:15:40 +01:00
parent 2d43600066
commit 9ee5048a43
15 changed files with 1883 additions and 235 deletions
+184 -62
View File
@@ -7,6 +7,8 @@ use cot::request::extractors::Path;
use cot::response::{IntoResponse, Redirect, Response};
use cot::router::{Route, Router};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::ops::Deref;
use tracing::info;
use cot::db::query;
@@ -74,12 +76,26 @@ struct LandingTemplate<'a> {
contact_info: String,
pricing_info: String,
seo_keywords: String,
testimonials: Vec<Testimonial>,
testimonials: Vec<TestimonialView>,
site_domain: String,
review_count: usize,
turnstile_site_key: String,
}
#[derive(Debug)]
struct TestimonialView {
testimonial: Testimonial,
image_url: Option<String>,
}
impl Deref for TestimonialView {
type Target = Testimonial;
fn deref(&self) -> &Self::Target {
&self.testimonial
}
}
#[derive(Debug, Template)]
#[template(path = "thank_you.html")]
struct ThankYouTemplate<'a> {
@@ -145,13 +161,33 @@ async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
testimonials.retain(|t| t.status == "active");
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
let review_count = testimonials.len();
let storage = crate::uploads::Storage::load(&db).await?;
let mut testimonial_views = Vec::with_capacity(testimonials.len());
for testimonial in testimonials {
let image_url = match testimonial.image_path.as_deref() {
Some(path) => Some(
storage
.public_url(
path,
format!("/testimonial-image/{}", testimonial.id.unwrap()),
)
.await
.map_err(crate::uploads::storage_error)?,
),
None => None,
};
testimonial_views.push(TestimonialView {
testimonial,
image_url,
});
}
let body = LandingTemplate {
t: lang.t(),
lang,
contact_info,
pricing_info,
seo_keywords,
testimonials,
testimonials: testimonial_views,
site_domain,
review_count,
turnstile_site_key,
@@ -213,7 +249,55 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
struct PortalVisit {
visit: Visit,
admin_name: String,
media: Vec<Media>,
media: Vec<PortalMediaView>,
}
#[derive(Clone, Debug)]
struct PortalMediaView {
media: Media,
url: String,
thumbnail_url: String,
}
impl Deref for PortalMediaView {
type Target = Media;
fn deref(&self) -> &Self::Target {
&self.media
}
}
async fn portal_media_view(
storage: &crate::uploads::Storage,
media: Media,
client_token: &str,
) -> cot::Result<PortalMediaView> {
let media_id = media.id.unwrap();
let url = storage
.public_url(
&media.file_path,
format!("/client/{client_token}/media/{media_id}"),
)
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_path =
if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) {
crate::uploads::thumbnail_db_path(&media.file_path)
} else {
media.file_path.clone()
};
let thumbnail_url = storage
.public_url(
&thumbnail_path,
format!("/client/{client_token}/media/{media_id}/thumbnail"),
)
.await
.map_err(crate::uploads::storage_error)?;
Ok(PortalMediaView {
media,
url,
thumbnail_url,
})
}
#[derive(Debug)]
@@ -334,36 +418,6 @@ async fn client_portal(
.then(a.time_start.cmp(&b.time_start))
});
let users = User::objects().all(&db).await?;
let all_media = Media::objects().all(&db).await?;
let build_portal_visit = |v: Visit| -> PortalVisit {
let uid: i64 = v.user_id.primary_key().unwrap();
let admin_name = users
.iter()
.find(|u| u.id.unwrap() == uid)
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
.unwrap_or_default();
let vid = v.id.unwrap();
let media: Vec<Media> = all_media
.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)
.unwrap_or(false)
})
.cloned()
.collect();
PortalVisit {
visit: v,
admin_name,
media,
}
};
let mut upcoming_visits = Vec::new();
let mut past_visits = Vec::new();
for v in visits {
@@ -379,6 +433,50 @@ async fn client_portal(
let page = requested_page.min(total_pages);
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
let visible_visit_ids: HashSet<i64> = past_visits[page_start..page_end]
.iter()
.chain(upcoming_visits.iter())
.map(|visit| visit.id.unwrap())
.collect();
let users = User::objects().all(&db).await?;
let all_media = Media::objects().all(&db).await?;
let storage = crate::uploads::Storage::load(&db).await?;
let mut media_by_visit: HashMap<i64, Vec<PortalMediaView>> = HashMap::new();
for media in all_media {
if media.status != "active" || media.client_id.primary_key().unwrap() != client_id {
continue;
}
let Some(visit_id) = media
.visit_id
.as_ref()
.map(|foreign_key| foreign_key.primary_key().unwrap())
else {
continue;
};
if !visible_visit_ids.contains(&visit_id) {
continue;
}
let media_view = portal_media_view(&storage, media, &client.media_token).await?;
media_by_visit.entry(visit_id).or_default().push(media_view);
}
let build_portal_visit = |v: Visit| -> PortalVisit {
let uid: i64 = v.user_id.primary_key().unwrap();
let admin_name = users
.iter()
.find(|u| u.id.unwrap() == uid)
.map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string())
.unwrap_or_default();
let vid = v.id.unwrap();
let media = media_by_visit.get(&vid).cloned().unwrap_or_default();
PortalVisit {
visit: v,
admin_name,
media,
}
};
let past = past_visits[page_start..page_end]
.iter()
.cloned()
@@ -759,21 +857,22 @@ async fn portal_media(
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
match {
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"heic" | "heif" => "image/heic",
"webp" => "image/webp",
"mp4" => "video/mp4",
"mov" => "video/quicktime",
"avi" => "video/x-msvideo",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
_ => "application/octet-stream",
};
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
} {
let storage = crate::uploads::Storage::load(&db).await?;
if storage.is_r2() {
let url = storage
.public_url(&media.file_path, String::new())
.await
.map_err(crate::uploads::storage_error)?;
return Redirect::new(url).into_response();
}
match crate::uploads::ranged_local_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
range.as_deref(),
)
.await
{
Ok(response) => Ok(response),
Err(err) => {
tracing::warn!(
@@ -815,11 +914,32 @@ async fn portal_media_thumbnail(
_ => return Html::new("404").into_response(),
}
}
match crate::uploads::ensure_thumbnail(&media.file_path).await {
Ok(path) => {
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
.await
.map_err(|error| cot::Error::internal(error.to_string()))?;
let storage = crate::uploads::Storage::load(&db).await?;
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
Ok(path) => path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
media.file_path.clone()
}
}
} else {
media.file_path.clone()
};
if storage.is_r2() {
let url = storage
.public_url(&display_path, String::new())
.await
.map_err(crate::uploads::storage_error)?;
return Redirect::new(url).into_response();
}
let content_type = if display_path == media.file_path {
crate::uploads::content_type_for_path(&media.file_path)
} else {
"image/jpeg"
};
match crate::uploads::ranged_local_file_response(&display_path, content_type, None).await {
Ok(mut response) => {
response.headers_mut().insert(
"cache-control",
"private, max-age=31536000, immutable".parse().unwrap(),
@@ -827,14 +947,8 @@ async fn portal_media_thumbnail(
Ok(response)
}
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
crate::uploads::ranged_file_response(
&media.file_path,
crate::uploads::content_type_for_path(&media.file_path),
None,
)
.await
.map_err(|error| cot::Error::internal(error.to_string()))
tracing::warn!(media_id, %error, "failed to read portal media thumbnail");
Html::new("404").into_response()
}
}
}
@@ -852,7 +966,15 @@ async fn serve_testimonial_image(
Some(p) => p.clone(),
None => return Html::new("404").into_response(),
};
match crate::uploads::read_db_file(&path).await {
let storage = crate::uploads::Storage::load(&db).await?;
if storage.is_r2() {
let url = storage
.public_url(&path, String::new())
.await
.map_err(crate::uploads::storage_error)?;
return Redirect::new(url).into_response();
}
match storage.read(&path).await {
Ok(data) => {
let content_type = match path.rsplit('.').next().unwrap_or("") {
"jpg" | "jpeg" => "image/jpeg",