Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
This commit is contained in:
+184
-62
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user