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:
Generated
+728
-48
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "web-petting"
|
name = "web-petting"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-run = "web-petting"
|
default-run = "web-petting"
|
||||||
|
|
||||||
@@ -24,3 +24,4 @@ tracing = "0.1"
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
web-push-native = "0.5"
|
web-push-native = "0.5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
aws-sdk-s3 = { version = "1", default-features = false, features = ["rustls", "rt-tokio"] }
|
||||||
|
|||||||
+2
-1
@@ -4,13 +4,14 @@ WORKDIR /app
|
|||||||
COPY Cargo.toml Cargo.lock* ./
|
COPY Cargo.toml Cargo.lock* ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY templates ./templates
|
COPY templates ./templates
|
||||||
RUN cargo build --release
|
RUN cargo build --release --bins
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /data
|
WORKDIR /data
|
||||||
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
ENV WEB_PETTING_UPLOAD_DIR=/data/uploads
|
||||||
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting
|
||||||
|
COPY --from=builder /app/target/release/migrate_uploads_to_r2 /usr/local/bin/migrate_uploads_to_r2
|
||||||
COPY static /app/static
|
COPY static /app/static
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["web-petting"]
|
CMD ["web-petting"]
|
||||||
|
|||||||
+272
-72
@@ -13,6 +13,7 @@ use image::codecs::jpeg::JpegEncoder;
|
|||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
use crate::i18n::{Lang, Translations};
|
use crate::i18n::{Lang, Translations};
|
||||||
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
|
use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit};
|
||||||
@@ -145,6 +146,7 @@ fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result<Option<Vec<u8
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn save_uploaded_image(
|
async fn save_uploaded_image(
|
||||||
|
storage: &crate::uploads::Storage,
|
||||||
upload_dir: &str,
|
upload_dir: &str,
|
||||||
file_id: uuid::Uuid,
|
file_id: uuid::Uuid,
|
||||||
ext: &str,
|
ext: &str,
|
||||||
@@ -152,18 +154,20 @@ async fn save_uploaded_image(
|
|||||||
) -> cot::Result<String> {
|
) -> cot::Result<String> {
|
||||||
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
|
if let Some(encoded) = transcode_uploaded_image(data, ext)? {
|
||||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg"));
|
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg"));
|
||||||
crate::uploads::write_db_file(&path, &encoded)
|
storage
|
||||||
|
.write(&path, &encoded)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
crate::uploads::ensure_thumbnail(&path)
|
crate::uploads::write_thumbnail(storage, &path, &encoded)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
} else {
|
} else {
|
||||||
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}"));
|
let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}"));
|
||||||
crate::uploads::write_db_file(&path, data)
|
storage
|
||||||
|
.write(&path, data)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,9 +341,12 @@ struct SettingsTemplate<'a> {
|
|||||||
admin_name: &'a str,
|
admin_name: &'a str,
|
||||||
settings: Vec<Setting>,
|
settings: Vec<Setting>,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
|
error: Option<&'a str>,
|
||||||
auth_password_checked: bool,
|
auth_password_checked: bool,
|
||||||
auth_sso_checked: bool,
|
auth_sso_checked: bool,
|
||||||
client_notifications_checked: bool,
|
client_notifications_checked: bool,
|
||||||
|
r2_enabled_checked: bool,
|
||||||
|
r2_secret_configured: bool,
|
||||||
push_subscribers: Vec<PushSubscriberItem>,
|
push_subscribers: Vec<PushSubscriberItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,12 +426,27 @@ struct ScheduleEditTemplate<'a> {
|
|||||||
visit: Visit,
|
visit: Visit,
|
||||||
client: Client,
|
client: Client,
|
||||||
users: Vec<User>,
|
users: Vec<User>,
|
||||||
media: Vec<Media>,
|
media: Vec<MediaView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct MediaView {
|
||||||
|
media: Media,
|
||||||
|
url: String,
|
||||||
|
thumbnail_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for MediaView {
|
||||||
|
type Target = Media;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.media
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct MediaItem {
|
struct MediaItem {
|
||||||
media: Media,
|
media: MediaView,
|
||||||
client_name: String,
|
client_name: String,
|
||||||
visit_date: Option<String>,
|
visit_date: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -453,6 +475,35 @@ struct MediaUploadTemplate<'a> {
|
|||||||
visit_label: &'a str,
|
visit_label: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn admin_media_view(
|
||||||
|
storage: &crate::uploads::Storage,
|
||||||
|
media: Media,
|
||||||
|
) -> cot::Result<MediaView> {
|
||||||
|
let media_id = media.id.unwrap();
|
||||||
|
let url = storage
|
||||||
|
.public_url(&media.file_path, format!("/admin/uploads/{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!("/admin/uploads/{media_id}/thumbnail"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?;
|
||||||
|
Ok(MediaView {
|
||||||
|
media,
|
||||||
|
url,
|
||||||
|
thumbnail_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Auth Handlers
|
// Auth Handlers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1254,15 +1305,28 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
|
|||||||
.find(|s| s.key == "client_notifications_enabled")
|
.find(|s| s.key == "client_notifications_enabled")
|
||||||
.map(|s| s.value == "true")
|
.map(|s| s.value == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let r2_enabled_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY)
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let r2_secret_configured = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY)
|
||||||
|
.map(|setting| !setting.value.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
let body = SettingsTemplate {
|
let body = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: false,
|
saved: false,
|
||||||
|
error: None,
|
||||||
auth_password_checked,
|
auth_password_checked,
|
||||||
auth_sso_checked,
|
auth_sso_checked,
|
||||||
client_notifications_checked,
|
client_notifications_checked,
|
||||||
|
r2_enabled_checked,
|
||||||
|
r2_secret_configured,
|
||||||
push_subscribers: load_push_subscribers(&db).await?,
|
push_subscribers: load_push_subscribers(&db).await?,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
@@ -1346,12 +1410,18 @@ struct SettingsForm {
|
|||||||
vapid_public_key: String,
|
vapid_public_key: String,
|
||||||
vapid_private_key: String,
|
vapid_private_key: String,
|
||||||
vapid_subject: String,
|
vapid_subject: String,
|
||||||
|
r2_account_id: String,
|
||||||
|
r2_bucket: String,
|
||||||
|
r2_access_key_id: String,
|
||||||
|
r2_secret_access_key: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
auth_password_enabled: Option<String>,
|
auth_password_enabled: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
auth_sso_enabled: Option<String>,
|
auth_sso_enabled: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
client_notifications_enabled: Option<String>,
|
client_notifications_enabled: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
r2_enabled: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
|
||||||
@@ -1374,6 +1444,21 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
let vapid_keys_changed = had_vapid_keys
|
let vapid_keys_changed = had_vapid_keys
|
||||||
&& (old_value("vapid_public_key").trim() != form.vapid_public_key.trim()
|
&& (old_value("vapid_public_key").trim() != form.vapid_public_key.trim()
|
||||||
|| old_value("vapid_private_key").trim() != form.vapid_private_key.trim());
|
|| old_value("vapid_private_key").trim() != form.vapid_private_key.trim());
|
||||||
|
let r2_secret_access_key = if form.r2_secret_access_key.trim().is_empty() {
|
||||||
|
old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY).to_string()
|
||||||
|
} else {
|
||||||
|
form.r2_secret_access_key.trim().to_string()
|
||||||
|
};
|
||||||
|
let requested_r2_enabled = form.r2_enabled.is_some();
|
||||||
|
let r2_config_valid = crate::uploads::R2Config::fields_are_valid(
|
||||||
|
&form.r2_account_id,
|
||||||
|
&form.r2_bucket,
|
||||||
|
&form.r2_access_key_id,
|
||||||
|
&r2_secret_access_key,
|
||||||
|
);
|
||||||
|
let r2_enabled = requested_r2_enabled && r2_config_valid;
|
||||||
|
let settings_error =
|
||||||
|
(requested_r2_enabled && !r2_config_valid).then_some(lang.t().settings_r2_error_incomplete);
|
||||||
|
|
||||||
for (key, value) in [
|
for (key, value) in [
|
||||||
("telegram_bot_token", form.telegram_bot_token),
|
("telegram_bot_token", form.telegram_bot_token),
|
||||||
@@ -1391,6 +1476,26 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
("vapid_public_key", form.vapid_public_key),
|
("vapid_public_key", form.vapid_public_key),
|
||||||
("vapid_private_key", form.vapid_private_key),
|
("vapid_private_key", form.vapid_private_key),
|
||||||
("vapid_subject", form.vapid_subject),
|
("vapid_subject", form.vapid_subject),
|
||||||
|
(
|
||||||
|
crate::uploads::R2_ACCOUNT_ID_KEY,
|
||||||
|
form.r2_account_id.trim().to_ascii_lowercase(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::uploads::R2_BUCKET_KEY,
|
||||||
|
form.r2_bucket.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::uploads::R2_ACCESS_KEY_ID_KEY,
|
||||||
|
form.r2_access_key_id.trim().to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::uploads::R2_SECRET_ACCESS_KEY_KEY,
|
||||||
|
r2_secret_access_key,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
crate::uploads::R2_ENABLED_KEY,
|
||||||
|
if r2_enabled { "true" } else { "false" }.to_string(),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"auth_password_enabled",
|
"auth_password_enabled",
|
||||||
if form.auth_password_enabled.is_some() {
|
if form.auth_password_enabled.is_some() {
|
||||||
@@ -1462,15 +1567,28 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
|
|||||||
.find(|s| s.key == "client_notifications_enabled")
|
.find(|s| s.key == "client_notifications_enabled")
|
||||||
.map(|s| s.value == "true")
|
.map(|s| s.value == "true")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let r2_enabled_checked = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY)
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let r2_secret_configured = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY)
|
||||||
|
.map(|setting| !setting.value.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
let rendered = SettingsTemplate {
|
let rendered = SettingsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name: &admin_name,
|
admin_name: &admin_name,
|
||||||
settings,
|
settings,
|
||||||
saved: true,
|
saved: settings_error.is_none(),
|
||||||
|
error: settings_error,
|
||||||
auth_password_checked,
|
auth_password_checked,
|
||||||
auth_sso_checked,
|
auth_sso_checked,
|
||||||
client_notifications_checked,
|
client_notifications_checked,
|
||||||
|
r2_enabled_checked,
|
||||||
|
r2_secret_configured,
|
||||||
push_subscribers: load_push_subscribers(&db).await?,
|
push_subscribers: load_push_subscribers(&db).await?,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
@@ -1964,6 +2082,11 @@ async fn schedule_edit_page(
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
});
|
});
|
||||||
visit_media.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
visit_media.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let mut media_views = Vec::with_capacity(visit_media.len());
|
||||||
|
for media in visit_media {
|
||||||
|
media_views.push(admin_media_view(&storage, media).await?);
|
||||||
|
}
|
||||||
let body = ScheduleEditTemplate {
|
let body = ScheduleEditTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
@@ -1971,7 +2094,7 @@ async fn schedule_edit_page(
|
|||||||
visit,
|
visit,
|
||||||
client,
|
client,
|
||||||
users,
|
users,
|
||||||
media: visit_media,
|
media: media_views,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -2157,28 +2280,25 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re
|
|||||||
let page = requested_page.min(total_pages);
|
let page = requested_page.min(total_pages);
|
||||||
let page_start = (page - 1) * MEDIA_PER_PAGE;
|
let page_start = (page - 1) * MEDIA_PER_PAGE;
|
||||||
|
|
||||||
let items: Vec<MediaItem> = media_list
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
.into_iter()
|
let mut items = Vec::new();
|
||||||
.skip(page_start)
|
for media in media_list.into_iter().skip(page_start).take(MEDIA_PER_PAGE) {
|
||||||
.take(MEDIA_PER_PAGE)
|
let cid: i64 = media.client_id.primary_key().unwrap();
|
||||||
.map(|m| {
|
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
|
||||||
let cid: i64 = m.client_id.primary_key().unwrap();
|
let visit_date = media
|
||||||
let client = clients_all.iter().find(|c| c.id.unwrap() == cid);
|
.visit_id
|
||||||
let visit_date = m
|
.as_ref()
|
||||||
.visit_id
|
.and_then(|fk| {
|
||||||
.as_ref()
|
let vid: i64 = fk.primary_key().unwrap();
|
||||||
.and_then(|fk| {
|
visits_all.iter().find(|v| v.id.unwrap() == vid)
|
||||||
let vid: i64 = fk.primary_key().unwrap();
|
})
|
||||||
visits_all.iter().find(|v| v.id.unwrap() == vid)
|
.map(|v| v.visit_date.to_string());
|
||||||
})
|
items.push(MediaItem {
|
||||||
.map(|v| v.visit_date.to_string());
|
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
|
||||||
MediaItem {
|
visit_date,
|
||||||
client_name: client.map(|c| c.name.clone()).unwrap_or_default(),
|
media: admin_media_view(&storage, media).await?,
|
||||||
visit_date,
|
});
|
||||||
media: m,
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let active_clients = clients_all
|
let active_clients = clients_all
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -2275,10 +2395,12 @@ async fn media_upload_submit(
|
|||||||
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||||
let mut multipart = multer::Multipart::new(stream, boundary);
|
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||||
|
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
let upload_dir = crate::uploads::media_dir(client_id, visit_id);
|
let upload_dir = crate::uploads::media_dir(client_id, visit_id);
|
||||||
crate::uploads::create_logical_dir(&upload_dir)
|
storage
|
||||||
|
.create_logical_dir(&upload_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
|
|
||||||
let mut caption = String::new();
|
let mut caption = String::new();
|
||||||
let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type)
|
let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type)
|
||||||
@@ -2326,12 +2448,13 @@ async fn media_upload_submit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let file_path = if file_type == "photo" {
|
let file_path = if file_type == "photo" {
|
||||||
save_uploaded_image(&upload_dir, file_id, &ext, &data).await?
|
save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?
|
||||||
} else {
|
} else {
|
||||||
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
|
||||||
crate::uploads::write_db_file(&path, &data)
|
storage
|
||||||
|
.write(&path, &data)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
path
|
path
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2382,11 +2505,12 @@ async fn media_delete(
|
|||||||
.get("referer")
|
.get("referer")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? {
|
||||||
let file_path = m.file_path.clone();
|
let file_path = m.file_path.clone();
|
||||||
m.status = "archived".to_string();
|
m.status = "archived".to_string();
|
||||||
m.save(&db).await?;
|
m.save(&db).await?;
|
||||||
if let Err(err) = crate::uploads::remove_db_file(&file_path).await {
|
if let Err(err) = storage.remove(&file_path).await {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
target: "uploads",
|
target: "uploads",
|
||||||
media_id,
|
media_id,
|
||||||
@@ -2397,10 +2521,8 @@ async fn media_delete(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let thumbnail_path = crate::uploads::thumbnail_db_path(&file_path);
|
let thumbnail_path = crate::uploads::thumbnail_db_path(&file_path);
|
||||||
if let Err(error) = crate::uploads::remove_db_file(&thumbnail_path).await {
|
if let Err(error) = storage.remove(&thumbnail_path).await {
|
||||||
if error.kind() != std::io::ErrorKind::NotFound {
|
tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail");
|
||||||
tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let redirect_url = referer
|
let redirect_url = referer
|
||||||
@@ -2423,11 +2545,35 @@ async fn serve_upload_thumbnail(
|
|||||||
Some(media) if media.status == "active" && media.file_type == "photo" => media,
|
Some(media) if media.status == "active" && media.file_type == "photo" => media,
|
||||||
_ => return Html::new("404").into_response(),
|
_ => return Html::new("404").into_response(),
|
||||||
};
|
};
|
||||||
match crate::uploads::ensure_thumbnail(&media.file_path).await {
|
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 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 display_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, display_content_type, None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(path) => {
|
Ok(path) => {
|
||||||
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
|
let mut response = path;
|
||||||
.await
|
|
||||||
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
"cache-control",
|
"cache-control",
|
||||||
"private, max-age=31536000, immutable".parse().unwrap(),
|
"private, max-age=31536000, immutable".parse().unwrap(),
|
||||||
@@ -2435,8 +2581,8 @@ async fn serve_upload_thumbnail(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(media_id, %error, "failed to create media thumbnail");
|
tracing::warn!(media_id, %error, "failed to read media thumbnail");
|
||||||
crate::uploads::ranged_file_response(
|
crate::uploads::ranged_local_file_response(
|
||||||
&media.file_path,
|
&media.file_path,
|
||||||
crate::uploads::content_type_for_path(&media.file_path),
|
crate::uploads::content_type_for_path(&media.file_path),
|
||||||
None,
|
None,
|
||||||
@@ -2470,21 +2616,22 @@ async fn serve_upload(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
match {
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
if storage.is_r2() {
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
let url = storage
|
||||||
"png" => "image/png",
|
.public_url(&media.file_path, String::new())
|
||||||
"heic" | "heif" => "image/heic",
|
.await
|
||||||
"webp" => "image/webp",
|
.map_err(crate::uploads::storage_error)?;
|
||||||
"mp4" => "video/mp4",
|
return Redirect::new(url).into_response();
|
||||||
"mov" => "video/quicktime",
|
}
|
||||||
"avi" => "video/x-msvideo",
|
|
||||||
"mkv" => "video/x-matroska",
|
match crate::uploads::ranged_local_file_response(
|
||||||
"webm" => "video/webm",
|
&media.file_path,
|
||||||
_ => "application/octet-stream",
|
crate::uploads::content_type_for_path(&media.file_path),
|
||||||
};
|
range.as_deref(),
|
||||||
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
)
|
||||||
} {
|
.await
|
||||||
|
{
|
||||||
Ok(response) => Ok(response),
|
Ok(response) => Ok(response),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -2510,7 +2657,43 @@ struct TestimonialsTemplate<'a> {
|
|||||||
t: &'a Translations,
|
t: &'a Translations,
|
||||||
lang: Lang,
|
lang: Lang,
|
||||||
admin_name: String,
|
admin_name: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<TestimonialView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TestimonialView {
|
||||||
|
testimonial: Testimonial,
|
||||||
|
image_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for TestimonialView {
|
||||||
|
type Target = Testimonial;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.testimonial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn admin_testimonial_view(
|
||||||
|
storage: &crate::uploads::Storage,
|
||||||
|
testimonial: Testimonial,
|
||||||
|
) -> cot::Result<TestimonialView> {
|
||||||
|
let image_url = match testimonial.image_path.as_deref() {
|
||||||
|
Some(path) => Some(
|
||||||
|
storage
|
||||||
|
.public_url(
|
||||||
|
path,
|
||||||
|
format!("/admin/testimonials/{}/image", testimonial.id.unwrap()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(crate::uploads::storage_error)?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(TestimonialView {
|
||||||
|
testimonial,
|
||||||
|
image_url,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn testimonials_page(
|
async fn testimonials_page(
|
||||||
@@ -2530,12 +2713,17 @@ async fn testimonials_page(
|
|||||||
.cmp(&b.sort_order)
|
.cmp(&b.sort_order)
|
||||||
.then(b.id.unwrap().cmp(&a.id.unwrap()))
|
.then(b.id.unwrap().cmp(&a.id.unwrap()))
|
||||||
});
|
});
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
let mut testimonial_views = Vec::with_capacity(testimonials.len());
|
||||||
|
for testimonial in testimonials {
|
||||||
|
testimonial_views.push(admin_testimonial_view(&storage, testimonial).await?);
|
||||||
|
}
|
||||||
|
|
||||||
let body = TestimonialsTemplate {
|
let body = TestimonialsTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
admin_name,
|
admin_name,
|
||||||
testimonials,
|
testimonials: testimonial_views,
|
||||||
}
|
}
|
||||||
.render()?;
|
.render()?;
|
||||||
html_response(body, lang)
|
html_response(body, lang)
|
||||||
@@ -2558,6 +2746,7 @@ async fn testimonial_add(
|
|||||||
let stream =
|
let stream =
|
||||||
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||||
let mut multipart = multer::Multipart::new(stream, boundary);
|
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut author_note = String::new();
|
let mut author_note = String::new();
|
||||||
@@ -2604,11 +2793,12 @@ async fn testimonial_add(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let upload_dir = crate::uploads::testimonials_dir();
|
let upload_dir = crate::uploads::testimonials_dir();
|
||||||
crate::uploads::create_logical_dir(&upload_dir)
|
storage
|
||||||
|
.create_logical_dir(&upload_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
let file_id = uuid::Uuid::new_v4();
|
let file_id = uuid::Uuid::new_v4();
|
||||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
let path = save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?;
|
||||||
image_path = Some(path);
|
image_path = Some(path);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -2698,6 +2888,7 @@ async fn testimonial_edit(
|
|||||||
let stream =
|
let stream =
|
||||||
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) });
|
||||||
let mut multipart = multer::Multipart::new(stream, boundary);
|
let mut multipart = multer::Multipart::new(stream, boundary);
|
||||||
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
|
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut author_note = String::new();
|
let mut author_note = String::new();
|
||||||
@@ -2752,11 +2943,12 @@ async fn testimonial_edit(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let upload_dir = crate::uploads::testimonials_dir();
|
let upload_dir = crate::uploads::testimonials_dir();
|
||||||
crate::uploads::create_logical_dir(&upload_dir)
|
storage
|
||||||
|
.create_logical_dir(&upload_dir)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(crate::uploads::storage_error)?;
|
||||||
let file_id = uuid::Uuid::new_v4();
|
let file_id = uuid::Uuid::new_v4();
|
||||||
let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?;
|
let path = save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?;
|
||||||
new_image_path = Some(path);
|
new_image_path = Some(path);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -2797,7 +2989,15 @@ async fn serve_testimonial_image(
|
|||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return Html::new("404").into_response(),
|
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) => {
|
Ok(data) => {
|
||||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#[allow(dead_code)]
|
||||||
|
#[path = "../models.rs"]
|
||||||
|
mod models;
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[path = "../uploads.rs"]
|
||||||
|
mod uploads;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use cot::db::{Database, Model};
|
||||||
|
use models::{Media, Testimonial};
|
||||||
|
|
||||||
|
fn database_url() -> String {
|
||||||
|
std::env::var("WEB_PETTING_DATABASE_URL")
|
||||||
|
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||||
|
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_thumbnail(
|
||||||
|
sources: &mut BTreeMap<String, bool>,
|
||||||
|
db_path: &str,
|
||||||
|
required: bool,
|
||||||
|
) -> bool {
|
||||||
|
if !uploads::supports_thumbnail(db_path) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match uploads::ensure_local_thumbnail(db_path).await {
|
||||||
|
Ok(thumbnail_path) => {
|
||||||
|
sources
|
||||||
|
.entry(thumbnail_path)
|
||||||
|
.and_modify(|is_required| *is_required |= required)
|
||||||
|
.or_insert(required);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Could not create thumbnail for {db_path}: {error}");
|
||||||
|
!required
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let db = Database::new(database_url()).await?;
|
||||||
|
let storage = uploads::Storage::load_configured_r2(&db).await?;
|
||||||
|
let mut sources: BTreeMap<String, bool> = BTreeMap::new();
|
||||||
|
let mut preparation_failed = false;
|
||||||
|
|
||||||
|
for media in Media::objects().all(&db).await? {
|
||||||
|
let required = media.status == "active";
|
||||||
|
sources
|
||||||
|
.entry(media.file_path.clone())
|
||||||
|
.and_modify(|is_required| *is_required |= required)
|
||||||
|
.or_insert(required);
|
||||||
|
if media.file_type == "photo"
|
||||||
|
&& !add_thumbnail(&mut sources, &media.file_path, required).await
|
||||||
|
{
|
||||||
|
preparation_failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for testimonial in Testimonial::objects().all(&db).await? {
|
||||||
|
let Some(image_path) = testimonial.image_path else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let required = testimonial.status == "active";
|
||||||
|
sources
|
||||||
|
.entry(image_path.clone())
|
||||||
|
.and_modify(|is_required| *is_required |= required)
|
||||||
|
.or_insert(required);
|
||||||
|
// Testimonial thumbnails are retained too, even though the landing page
|
||||||
|
// currently displays the processed full-size image.
|
||||||
|
let _ = add_thumbnail(&mut sources, &image_path, false).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uploaded = 0usize;
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
let mut missing = 0usize;
|
||||||
|
let mut failed = 0usize;
|
||||||
|
|
||||||
|
for (db_path, required) in sources {
|
||||||
|
let local_path = uploads::resolve_db_path(&db_path);
|
||||||
|
match tokio::fs::try_exists(&local_path).await {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
eprintln!("Missing local file {}", local_path.display());
|
||||||
|
missing += 1;
|
||||||
|
if required {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!(
|
||||||
|
"Could not inspect local file {}: {error}",
|
||||||
|
local_path.display()
|
||||||
|
);
|
||||||
|
missing += 1;
|
||||||
|
if required {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if storage.exists(&db_path).await? {
|
||||||
|
println!("Already in R2: {db_path}");
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match storage.upload_local_copy(&db_path, &local_path).await {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("Uploaded: {db_path}");
|
||||||
|
uploaded += 1;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Failed to upload {db_path}: {error}");
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close().await?;
|
||||||
|
println!(
|
||||||
|
"Migration summary: uploaded={uploaded}, already_present={skipped}, missing_local={missing}, failed={failed}"
|
||||||
|
);
|
||||||
|
println!("Local files were not deleted.");
|
||||||
|
|
||||||
|
if preparation_failed || failed > 0 {
|
||||||
|
return Err("R2 migration did not complete successfully".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("failed to build Tokio runtime");
|
||||||
|
if let Err(error) = runtime.block_on(run()) {
|
||||||
|
eprintln!("Migration failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -151,6 +151,16 @@ pub struct Translations {
|
|||||||
pub settings_section_captcha: &'static str,
|
pub settings_section_captcha: &'static str,
|
||||||
pub settings_section_oidc: &'static str,
|
pub settings_section_oidc: &'static str,
|
||||||
pub settings_section_general: &'static str,
|
pub settings_section_general: &'static str,
|
||||||
|
pub settings_section_storage: &'static str,
|
||||||
|
pub settings_r2_enabled: &'static str,
|
||||||
|
pub settings_r2_help: &'static str,
|
||||||
|
pub settings_r2_account_id: &'static str,
|
||||||
|
pub settings_r2_bucket: &'static str,
|
||||||
|
pub settings_r2_access_key_id: &'static str,
|
||||||
|
pub settings_r2_secret_access_key: &'static str,
|
||||||
|
pub settings_r2_secret_unchanged: &'static str,
|
||||||
|
pub settings_r2_migration_help: &'static str,
|
||||||
|
pub settings_r2_error_incomplete: &'static str,
|
||||||
pub settings_client_notifications_enabled: &'static str,
|
pub settings_client_notifications_enabled: &'static str,
|
||||||
pub settings_client_notifications_help: &'static str,
|
pub settings_client_notifications_help: &'static str,
|
||||||
pub settings_vapid_public_key: &'static str,
|
pub settings_vapid_public_key: &'static str,
|
||||||
@@ -411,6 +421,16 @@ static RU: Translations = Translations {
|
|||||||
settings_section_captcha: "Защита от ботов",
|
settings_section_captcha: "Защита от ботов",
|
||||||
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
settings_section_oidc: "Единый вход (SSO / OIDC)",
|
||||||
settings_section_general: "Сайт",
|
settings_section_general: "Сайт",
|
||||||
|
settings_section_storage: "Хранилище медиа",
|
||||||
|
settings_r2_enabled: "Использовать Cloudflare R2",
|
||||||
|
settings_r2_help: "Пока R2 выключен или настроен не полностью, медиа хранится локально. После включения новые файлы и миниатюры загружаются в приватный бакет, а страницы получают подписанные ссылки на 6 часов.",
|
||||||
|
settings_r2_account_id: "Cloudflare Account ID",
|
||||||
|
settings_r2_bucket: "Имя R2-бакета",
|
||||||
|
settings_r2_access_key_id: "R2 Access Key ID",
|
||||||
|
settings_r2_secret_access_key: "R2 Secret Access Key",
|
||||||
|
settings_r2_secret_unchanged: "Секрет уже сохранён; оставьте поле пустым, чтобы не менять его",
|
||||||
|
settings_r2_migration_help: "Порядок перехода: сохраните реквизиты с выключенным R2, выполните в контейнере migrate_uploads_to_r2, затем включите R2 и отключите PVC. Для бакета разрешите CORS GET/HEAD с домена сайта и заголовок Range.",
|
||||||
|
settings_r2_error_incomplete: "R2 не включён: проверьте Account ID, имя бакета, Access Key ID и Secret Access Key.",
|
||||||
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
|
settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления",
|
||||||
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
|
settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.",
|
||||||
settings_vapid_public_key: "VAPID — публичный ключ",
|
settings_vapid_public_key: "VAPID — публичный ключ",
|
||||||
@@ -661,6 +681,16 @@ static EN: Translations = Translations {
|
|||||||
settings_section_captcha: "Bot protection",
|
settings_section_captcha: "Bot protection",
|
||||||
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
|
||||||
settings_section_general: "Site",
|
settings_section_general: "Site",
|
||||||
|
settings_section_storage: "Media storage",
|
||||||
|
settings_r2_enabled: "Use Cloudflare R2",
|
||||||
|
settings_r2_help: "While R2 is disabled or incomplete, media stays in local storage. Once enabled, new files and thumbnails are uploaded to the private bucket and pages receive signed URLs valid for 6 hours.",
|
||||||
|
settings_r2_account_id: "Cloudflare Account ID",
|
||||||
|
settings_r2_bucket: "R2 bucket name",
|
||||||
|
settings_r2_access_key_id: "R2 Access Key ID",
|
||||||
|
settings_r2_secret_access_key: "R2 Secret Access Key",
|
||||||
|
settings_r2_secret_unchanged: "A secret is already stored; leave this blank to keep it unchanged",
|
||||||
|
settings_r2_migration_help: "Migration order: save the credentials with R2 disabled, run migrate_uploads_to_r2 inside the container, then enable R2 and detach the PVC. Allow CORS GET/HEAD from the site domain and the Range header on the bucket.",
|
||||||
|
settings_r2_error_incomplete: "R2 was not enabled: check the Account ID, bucket name, Access Key ID, and Secret Access Key.",
|
||||||
settings_client_notifications_enabled: "Allow client browser notifications",
|
settings_client_notifications_enabled: "Allow client browser notifications",
|
||||||
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
|
settings_client_notifications_help: "Shows clients the completed-visit notification setting.",
|
||||||
settings_vapid_public_key: "VAPID public key",
|
settings_vapid_public_key: "VAPID public key",
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ mod tz;
|
|||||||
mod uploads;
|
mod uploads;
|
||||||
mod web_push;
|
mod web_push;
|
||||||
|
|
||||||
use tracing_subscriber;
|
|
||||||
|
|
||||||
use cot::cli::CliMetadata;
|
use cot::cli::CliMetadata;
|
||||||
use cot::config::{
|
use cot::config::{
|
||||||
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
|
||||||
|
|||||||
+184
-62
@@ -7,6 +7,8 @@ use cot::request::extractors::Path;
|
|||||||
use cot::response::{IntoResponse, Redirect, Response};
|
use cot::response::{IntoResponse, Redirect, Response};
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::ops::Deref;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use cot::db::query;
|
use cot::db::query;
|
||||||
@@ -74,12 +76,26 @@ struct LandingTemplate<'a> {
|
|||||||
contact_info: String,
|
contact_info: String,
|
||||||
pricing_info: String,
|
pricing_info: String,
|
||||||
seo_keywords: String,
|
seo_keywords: String,
|
||||||
testimonials: Vec<Testimonial>,
|
testimonials: Vec<TestimonialView>,
|
||||||
site_domain: String,
|
site_domain: String,
|
||||||
review_count: usize,
|
review_count: usize,
|
||||||
turnstile_site_key: String,
|
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)]
|
#[derive(Debug, Template)]
|
||||||
#[template(path = "thank_you.html")]
|
#[template(path = "thank_you.html")]
|
||||||
struct ThankYouTemplate<'a> {
|
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.retain(|t| t.status == "active");
|
||||||
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order));
|
||||||
let review_count = testimonials.len();
|
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 {
|
let body = LandingTemplate {
|
||||||
t: lang.t(),
|
t: lang.t(),
|
||||||
lang,
|
lang,
|
||||||
contact_info,
|
contact_info,
|
||||||
pricing_info,
|
pricing_info,
|
||||||
seo_keywords,
|
seo_keywords,
|
||||||
testimonials,
|
testimonials: testimonial_views,
|
||||||
site_domain,
|
site_domain,
|
||||||
review_count,
|
review_count,
|
||||||
turnstile_site_key,
|
turnstile_site_key,
|
||||||
@@ -213,7 +249,55 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result<Response> {
|
|||||||
struct PortalVisit {
|
struct PortalVisit {
|
||||||
visit: Visit,
|
visit: Visit,
|
||||||
admin_name: String,
|
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)]
|
#[derive(Debug)]
|
||||||
@@ -334,36 +418,6 @@ async fn client_portal(
|
|||||||
.then(a.time_start.cmp(&b.time_start))
|
.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 upcoming_visits = Vec::new();
|
||||||
let mut past_visits = Vec::new();
|
let mut past_visits = Vec::new();
|
||||||
for v in visits {
|
for v in visits {
|
||||||
@@ -379,6 +433,50 @@ async fn client_portal(
|
|||||||
let page = requested_page.min(total_pages);
|
let page = requested_page.min(total_pages);
|
||||||
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE;
|
||||||
let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len());
|
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]
|
let past = past_visits[page_start..page_end]
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -759,21 +857,22 @@ async fn portal_media(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.map(str::to_owned);
|
.map(str::to_owned);
|
||||||
|
|
||||||
match {
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
let content_type = match media.file_path.rsplit('.').next().unwrap_or("") {
|
if storage.is_r2() {
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
let url = storage
|
||||||
"png" => "image/png",
|
.public_url(&media.file_path, String::new())
|
||||||
"heic" | "heif" => "image/heic",
|
.await
|
||||||
"webp" => "image/webp",
|
.map_err(crate::uploads::storage_error)?;
|
||||||
"mp4" => "video/mp4",
|
return Redirect::new(url).into_response();
|
||||||
"mov" => "video/quicktime",
|
}
|
||||||
"avi" => "video/x-msvideo",
|
|
||||||
"mkv" => "video/x-matroska",
|
match crate::uploads::ranged_local_file_response(
|
||||||
"webm" => "video/webm",
|
&media.file_path,
|
||||||
_ => "application/octet-stream",
|
crate::uploads::content_type_for_path(&media.file_path),
|
||||||
};
|
range.as_deref(),
|
||||||
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await
|
)
|
||||||
} {
|
.await
|
||||||
|
{
|
||||||
Ok(response) => Ok(response),
|
Ok(response) => Ok(response),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -815,11 +914,32 @@ async fn portal_media_thumbnail(
|
|||||||
_ => return Html::new("404").into_response(),
|
_ => return Html::new("404").into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match crate::uploads::ensure_thumbnail(&media.file_path).await {
|
let storage = crate::uploads::Storage::load(&db).await?;
|
||||||
Ok(path) => {
|
let display_path = if crate::uploads::supports_thumbnail(&media.file_path) {
|
||||||
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None)
|
match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await {
|
||||||
.await
|
Ok(path) => path,
|
||||||
.map_err(|error| cot::Error::internal(error.to_string()))?;
|
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(
|
response.headers_mut().insert(
|
||||||
"cache-control",
|
"cache-control",
|
||||||
"private, max-age=31536000, immutable".parse().unwrap(),
|
"private, max-age=31536000, immutable".parse().unwrap(),
|
||||||
@@ -827,14 +947,8 @@ async fn portal_media_thumbnail(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
|
tracing::warn!(media_id, %error, "failed to read portal media thumbnail");
|
||||||
crate::uploads::ranged_file_response(
|
Html::new("404").into_response()
|
||||||
&media.file_path,
|
|
||||||
crate::uploads::content_type_for_path(&media.file_path),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| cot::Error::internal(error.to_string()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,7 +966,15 @@ async fn serve_testimonial_image(
|
|||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return Html::new("404").into_response(),
|
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) => {
|
Ok(data) => {
|
||||||
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
let content_type = match path.rsplit('.').next().unwrap_or("") {
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
|||||||
+465
-32
@@ -1,15 +1,345 @@
|
|||||||
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use cot::db::{Database, Model};
|
||||||
use cot::response::Response;
|
use cot::response::Response;
|
||||||
use cot::{Body, StatusCode};
|
use cot::{Body, StatusCode};
|
||||||
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
use crate::models::Setting;
|
||||||
|
|
||||||
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
const THUMBNAIL_MAX_DIMENSION: u32 = 360;
|
||||||
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
|
const THUMBNAIL_JPEG_QUALITY: u8 = 70;
|
||||||
|
const PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||||
|
|
||||||
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
const DEFAULT_UPLOAD_DIR: &str = "uploads";
|
||||||
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR";
|
||||||
|
|
||||||
|
pub const R2_ENABLED_KEY: &str = "r2_enabled";
|
||||||
|
pub const R2_ACCOUNT_ID_KEY: &str = "r2_account_id";
|
||||||
|
pub const R2_BUCKET_KEY: &str = "r2_bucket";
|
||||||
|
pub const R2_ACCESS_KEY_ID_KEY: &str = "r2_access_key_id";
|
||||||
|
pub const R2_SECRET_ACCESS_KEY_KEY: &str = "r2_secret_access_key";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StorageError(String);
|
||||||
|
|
||||||
|
impl StorageError {
|
||||||
|
fn new(context: &str, error: impl fmt::Display) -> Self {
|
||||||
|
Self(format!("{context}: {error}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for StorageError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StorageError {}
|
||||||
|
|
||||||
|
pub type StorageResult<T> = Result<T, StorageError>;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub struct R2Config {
|
||||||
|
pub account_id: String,
|
||||||
|
pub bucket: String,
|
||||||
|
pub access_key_id: String,
|
||||||
|
pub secret_access_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl R2Config {
|
||||||
|
pub fn from_settings(settings: &[Setting]) -> Option<Self> {
|
||||||
|
let value = |key: &str| {
|
||||||
|
settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == key)
|
||||||
|
.map(|setting| setting.value.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let config = Self {
|
||||||
|
account_id: value(R2_ACCOUNT_ID_KEY),
|
||||||
|
bucket: value(R2_BUCKET_KEY),
|
||||||
|
access_key_id: value(R2_ACCESS_KEY_ID_KEY),
|
||||||
|
secret_access_key: value(R2_SECRET_ACCESS_KEY_KEY),
|
||||||
|
};
|
||||||
|
config.is_valid().then_some(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields_are_valid(
|
||||||
|
account_id: &str,
|
||||||
|
bucket: &str,
|
||||||
|
access_key_id: &str,
|
||||||
|
secret_access_key: &str,
|
||||||
|
) -> bool {
|
||||||
|
let account_id = account_id.trim();
|
||||||
|
let bucket = bucket.trim();
|
||||||
|
account_id.len() == 32
|
||||||
|
&& account_id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||||
|
&& (3..=63).contains(&bucket.len())
|
||||||
|
&& !bucket.starts_with('-')
|
||||||
|
&& !bucket.ends_with('-')
|
||||||
|
&& bucket
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||||
|
&& !access_key_id.trim().is_empty()
|
||||||
|
&& !secret_access_key.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
Self::fields_are_valid(
|
||||||
|
&self.account_id,
|
||||||
|
&self.bucket,
|
||||||
|
&self.access_key_id,
|
||||||
|
&self.secret_access_key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint(&self) -> String {
|
||||||
|
format!("https://{}.r2.cloudflarestorage.com", self.account_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct R2Storage {
|
||||||
|
client: Client,
|
||||||
|
bucket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl R2Storage {
|
||||||
|
fn new(config: R2Config) -> Self {
|
||||||
|
let endpoint = config.endpoint();
|
||||||
|
let bucket = config.bucket.clone();
|
||||||
|
let credentials = Credentials::new(
|
||||||
|
config.access_key_id,
|
||||||
|
config.secret_access_key,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"web-petting-r2",
|
||||||
|
);
|
||||||
|
let sdk_config = aws_sdk_s3::Config::builder()
|
||||||
|
.behavior_version(BehaviorVersion::latest())
|
||||||
|
.endpoint_url(endpoint)
|
||||||
|
.credentials_provider(credentials)
|
||||||
|
.region(Region::new("auto"))
|
||||||
|
.build();
|
||||||
|
Self {
|
||||||
|
client: Client::from_conf(sdk_config),
|
||||||
|
bucket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||||
|
self.put_stream(db_path, ByteStream::from(data.to_vec()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_stream(&self, db_path: &str, body: ByteStream) -> StorageResult<()> {
|
||||||
|
self.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.content_type(content_type_for_path(db_path))
|
||||||
|
.cache_control("private, max-age=21600")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to upload object to R2", error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to download object from R2", error))?;
|
||||||
|
let bytes = response
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read R2 response body", error))?;
|
||||||
|
Ok(bytes.into_bytes().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, db_path: &str) -> StorageResult<()> {
|
||||||
|
self.client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to delete object from R2", error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||||
|
match self
|
||||||
|
.client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(error)
|
||||||
|
if error
|
||||||
|
.as_service_error()
|
||||||
|
.is_some_and(|service_error| service_error.is_not_found()) =>
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
Err(error) => Err(StorageError::new("failed to inspect R2 object", error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn presigned_get_url(&self, db_path: &str) -> StorageResult<String> {
|
||||||
|
let config = PresigningConfig::expires_in(PRESIGNED_URL_TTL)
|
||||||
|
.map_err(|error| StorageError::new("failed to configure R2 signed URL", error))?;
|
||||||
|
let request = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(object_key(db_path))
|
||||||
|
.presigned(config)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to sign R2 object URL", error))?;
|
||||||
|
Ok(request.uri().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum Storage {
|
||||||
|
Local,
|
||||||
|
R2(R2Storage),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub async fn load(db: &Database) -> cot::Result<Self> {
|
||||||
|
let settings = Setting::objects().all(db).await?;
|
||||||
|
let enabled = settings
|
||||||
|
.iter()
|
||||||
|
.find(|setting| setting.key == R2_ENABLED_KEY)
|
||||||
|
.map(|setting| setting.value == "true")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(Self::Local);
|
||||||
|
}
|
||||||
|
match R2Config::from_settings(&settings) {
|
||||||
|
Some(config) => Ok(Self::R2(R2Storage::new(config))),
|
||||||
|
None => {
|
||||||
|
tracing::error!(
|
||||||
|
target: "uploads",
|
||||||
|
"R2 is enabled but its configuration is incomplete; using local storage"
|
||||||
|
);
|
||||||
|
Ok(Self::Local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configured R2 even when it is not enabled yet. This lets the
|
||||||
|
/// one-time migration run before the site is switched away from local storage.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn load_configured_r2(db: &Database) -> cot::Result<Self> {
|
||||||
|
let settings = Setting::objects().all(db).await?;
|
||||||
|
let config = R2Config::from_settings(&settings).ok_or_else(|| {
|
||||||
|
cot::Error::internal("R2 settings are missing or invalid".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Self::R2(R2Storage::new(config)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_r2(&self) -> bool {
|
||||||
|
matches!(self, Self::R2(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_logical_dir(&self, db_dir: &str) -> StorageResult<()> {
|
||||||
|
if matches!(self, Self::Local) {
|
||||||
|
tokio::fs::create_dir_all(resolve_db_path(db_dir))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create upload directory", error))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write(&self, db_path: &str, data: &[u8]) -> StorageResult<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local => {
|
||||||
|
let physical_path = resolve_db_path(db_path);
|
||||||
|
if let Some(parent) = physical_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||||
|
StorageError::new("failed to create upload directory", error)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(physical_path, data)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to write uploaded file", error))
|
||||||
|
}
|
||||||
|
Self::R2(storage) => storage.put(db_path, data).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(&self, db_path: &str) -> StorageResult<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
Self::Local => tokio::fs::read(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read uploaded file", error)),
|
||||||
|
Self::R2(storage) => storage.get(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(&self, db_path: &str) -> StorageResult<()> {
|
||||||
|
match self {
|
||||||
|
Self::Local => match tokio::fs::remove_file(resolve_db_path(db_path)).await {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(StorageError::new("failed to remove uploaded file", error)),
|
||||||
|
},
|
||||||
|
Self::R2(storage) => storage.delete(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exists(&self, db_path: &str) -> StorageResult<bool> {
|
||||||
|
match self {
|
||||||
|
Self::Local => tokio::fs::try_exists(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to inspect uploaded file", error)),
|
||||||
|
Self::R2(storage) => storage.exists(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn public_url(&self, db_path: &str, local_fallback: String) -> StorageResult<String> {
|
||||||
|
match self {
|
||||||
|
Self::Local => Ok(local_fallback),
|
||||||
|
Self::R2(storage) => storage.presigned_get_url(db_path).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a local PVC file into R2 without loading large videos into memory.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> {
|
||||||
|
let Self::R2(storage) = self else {
|
||||||
|
return Err(StorageError::new(
|
||||||
|
"failed to migrate local file",
|
||||||
|
"R2 storage is not configured",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let body = ByteStream::from_path(local_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to open local file for migration", error))?;
|
||||||
|
storage.put_stream(db_path, body).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
pub fn media_dir(client_id: i64, visit_id: i64) -> String {
|
||||||
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}")
|
||||||
}
|
}
|
||||||
@@ -44,20 +374,11 @@ pub fn resolved_display_path(db_path: &str) -> String {
|
|||||||
resolve_db_path(db_path).display().to_string()
|
resolve_db_path(db_path).display().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> {
|
pub fn object_key(db_path: &str) -> String {
|
||||||
tokio::fs::create_dir_all(resolve_db_path(db_dir)).await
|
db_path
|
||||||
}
|
.replace('\\', "/")
|
||||||
|
.trim_start_matches('/')
|
||||||
pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> {
|
.to_string()
|
||||||
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 fn thumbnail_db_path(db_path: &str) -> String {
|
pub fn thumbnail_db_path(db_path: &str) -> String {
|
||||||
@@ -67,8 +388,26 @@ pub fn thumbnail_db_path(db_path: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn supports_thumbnail(db_path: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
db_path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str(),
|
||||||
|
"jpg" | "jpeg" | "png" | "webp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn content_type_for_path(path: &str) -> &'static str {
|
pub fn content_type_for_path(path: &str) -> &'static str {
|
||||||
match path.rsplit('.').next().unwrap_or("") {
|
match path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
"png" => "image/png",
|
"png" => "image/png",
|
||||||
"heic" | "heif" => "image/heic",
|
"heic" | "heif" => "image/heic",
|
||||||
@@ -82,21 +421,63 @@ pub fn content_type_for_path(path: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_thumbnail(db_path: &str) -> std::io::Result<String> {
|
fn encode_thumbnail(data: &[u8]) -> StorageResult<Vec<u8>> {
|
||||||
let thumbnail_path = thumbnail_db_path(db_path);
|
let image = image::load_from_memory(data)
|
||||||
if tokio::fs::try_exists(resolve_db_path(&thumbnail_path)).await? {
|
.map_err(|error| StorageError::new("failed to decode image for thumbnail", error))?;
|
||||||
return Ok(thumbnail_path);
|
|
||||||
}
|
|
||||||
let data = read_db_file(db_path).await?;
|
|
||||||
let image = image::load_from_memory(&data)
|
|
||||||
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
|
|
||||||
let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION);
|
let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION);
|
||||||
let rgb = thumbnail.to_rgb8();
|
let rgb = thumbnail.to_rgb8();
|
||||||
let mut encoded = Vec::new();
|
let mut encoded = Vec::new();
|
||||||
let mut encoder =
|
let mut encoder = JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY);
|
||||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY);
|
encoder
|
||||||
encoder.encode_image(&rgb).map_err(std::io::Error::other)?;
|
.encode_image(&rgb)
|
||||||
write_db_file(&thumbnail_path, &encoded).await?;
|
.map_err(|error| StorageError::new("failed to encode image thumbnail", error))?;
|
||||||
|
Ok(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_thumbnail(storage: &Storage, db_path: &str) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
if storage.exists(&thumbnail_path).await? {
|
||||||
|
return Ok(thumbnail_path);
|
||||||
|
}
|
||||||
|
let data = storage.read(db_path).await?;
|
||||||
|
write_thumbnail(storage, db_path, &data).await?;
|
||||||
|
Ok(thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_thumbnail(
|
||||||
|
storage: &Storage,
|
||||||
|
db_path: &str,
|
||||||
|
source_data: &[u8],
|
||||||
|
) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
let encoded = encode_thumbnail(source_data)?;
|
||||||
|
storage.write(&thumbnail_path, &encoded).await?;
|
||||||
|
Ok(thumbnail_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a missing thumbnail from the local PVC even when R2 is enabled.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult<String> {
|
||||||
|
let thumbnail_path = thumbnail_db_path(db_path);
|
||||||
|
if tokio::fs::try_exists(resolve_db_path(&thumbnail_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to inspect local thumbnail", error))?
|
||||||
|
{
|
||||||
|
return Ok(thumbnail_path);
|
||||||
|
}
|
||||||
|
let data = tokio::fs::read(resolve_db_path(db_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to read local image", error))?;
|
||||||
|
let encoded = encode_thumbnail(&data)?;
|
||||||
|
let physical_path = resolve_db_path(&thumbnail_path);
|
||||||
|
if let Some(parent) = physical_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to create thumbnail directory", error))?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(physical_path, encoded)
|
||||||
|
.await
|
||||||
|
.map_err(|error| StorageError::new("failed to write local thumbnail", error))?;
|
||||||
Ok(thumbnail_path)
|
Ok(thumbnail_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +536,8 @@ fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange {
|
|||||||
ByteRange::Partial { start, end }
|
ByteRange::Partial { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request.
|
/// Read a local file into an HTTP response, honoring one `Range: bytes=...` request.
|
||||||
pub async fn ranged_file_response(
|
pub async fn ranged_local_file_response(
|
||||||
db_path: &str,
|
db_path: &str,
|
||||||
content_type: &str,
|
content_type: &str,
|
||||||
range_header: Option<&str>,
|
range_header: Option<&str>,
|
||||||
@@ -217,13 +598,13 @@ pub async fn ranged_file_response(
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> {
|
pub fn storage_error(error: StorageError) -> cot::Error {
|
||||||
tokio::fs::remove_file(resolve_db_path(db_path)).await
|
cot::Error::internal(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ByteRange, parse_byte_range};
|
use super::{ByteRange, R2Config, R2Storage, object_key, parse_byte_range};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_byte_ranges() {
|
fn parses_byte_ranges() {
|
||||||
@@ -244,4 +625,56 @@ mod tests {
|
|||||||
ByteRange::Unsatisfiable
|
ByteRange::Unsatisfiable
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_r2_fields() {
|
||||||
|
assert!(R2Config::fields_are_valid(
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"pet-media",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
assert!(!R2Config::fields_are_valid(
|
||||||
|
"not-an-account",
|
||||||
|
"pet-media",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
assert!(!R2Config::fields_are_valid(
|
||||||
|
"0123456789abcdef0123456789abcdef",
|
||||||
|
"Invalid_Bucket",
|
||||||
|
"access-key",
|
||||||
|
"secret-key",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_database_paths_to_object_keys() {
|
||||||
|
assert_eq!(object_key("uploads/1/photo.jpg"), "uploads/1/photo.jpg");
|
||||||
|
assert_eq!(
|
||||||
|
object_key("/data/uploads/photo.jpg"),
|
||||||
|
"data/uploads/photo.jpg"
|
||||||
|
);
|
||||||
|
assert_eq!(object_key("uploads\\1\\photo.jpg"), "uploads/1/photo.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signs_r2_urls_for_six_hours() {
|
||||||
|
let storage = R2Storage::new(R2Config {
|
||||||
|
account_id: "0123456789abcdef0123456789abcdef".to_string(),
|
||||||
|
bucket: "pet-media".to_string(),
|
||||||
|
access_key_id: "access-key".to_string(),
|
||||||
|
secret_access_key: "secret-key".to_string(),
|
||||||
|
});
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let url = runtime
|
||||||
|
.block_on(storage.presigned_get_url("uploads/1/photo.jpg"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(url.contains("X-Amz-Expires=21600"));
|
||||||
|
assert!(url.contains("uploads/1/photo.jpg"));
|
||||||
|
assert!(url.contains("pet-media"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,13 @@
|
|||||||
{% for item in &items %}
|
{% for item in &items %}
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
{% if item.media.file_type == "photo" %}
|
{% if item.media.file_type == "photo" %}
|
||||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="photo">
|
<a href="{{ item.media.url }}" data-lightbox="photo">
|
||||||
<img src="/admin/uploads/{{ item.media.id.unwrap() }}/thumbnail" alt="" loading="lazy">
|
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ item.media.id.unwrap() }}" data-lightbox="video">
|
<a href="{{ item.media.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb">
|
<div class="video-thumb">
|
||||||
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
<video src="{{ item.media.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -113,13 +113,13 @@
|
|||||||
{% for m in &media %}
|
{% for m in &media %}
|
||||||
<div class="visit-media-item">
|
<div class="visit-media-item">
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="/admin/uploads/{{ m.id.unwrap() }}/thumbnail" alt="" loading="lazy">
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/admin/uploads/{{ m.id.unwrap() }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="video-thumb-sm">
|
<div class="video-thumb-sm">
|
||||||
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
<video src="{{ m.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
{% if saved %}
|
{% if saved %}
|
||||||
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
|
<div class="notification is-success is-light">{{ t.settings_saved }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if let Some(message) = error %}
|
||||||
|
<div class="notification is-danger is-light">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-card">
|
<div class="form-card">
|
||||||
<form method="post" action="/admin/settings/save">
|
<form method="post" action="/admin/settings/save">
|
||||||
@@ -58,6 +61,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_storage }}</h3>
|
||||||
|
<div class="notification is-info is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin-bottom:0.85rem;">
|
||||||
|
<p>{{ t.settings_r2_help }}</p>
|
||||||
|
<p style="margin-top:0.45rem;">{{ t.settings_r2_migration_help }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
|
||||||
|
{{ t.settings_r2_enabled }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_r2_account_id }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="r2_account_id" maxlength="32" autocomplete="off" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_r2_bucket }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="r2_bucket" autocomplete="off" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_r2_access_key_id }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="text" name="r2_access_key_id" autocomplete="off" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">{{ t.settings_r2_secret_access_key }}</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="password" name="r2_secret_access_key" autocomplete="new-password"{% if r2_secret_configured %} placeholder="{{ t.settings_r2_secret_unchanged }}"{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
<div class="tm-view" id="view-{{ item.id.unwrap() }}">
|
||||||
<div class="item-card-header">
|
<div class="item-card-header">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||||
{% if item.image_path.is_some() %}
|
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
|
<div style="font-size:0.95rem;line-height:1.5;">{{ item.text }}</div>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
{% if item.image_path.is_some() %}
|
{% if item.image_path.is_some() %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem;">
|
||||||
<img src="/admin/testimonials/{{ item.id.unwrap() }}/image" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">
|
{% if let Some(image_url) = item.image_url.as_deref() %}<img src="{{ image_url }}" alt="" style="width:48px;height:48px;border-radius:50%;object-fit:cover;">{% endif %}
|
||||||
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
<label style="font-size:0.85rem;cursor:pointer;color:#888;">
|
||||||
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
<input type="checkbox" name="remove_image" value="1"> {{ t.testimonials_remove_image }}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -191,13 +191,13 @@
|
|||||||
<div class="media-row">
|
<div class="media-row">
|
||||||
{% for m in &pv.media %}
|
{% for m in &pv.media %}
|
||||||
{% if m.file_type == "photo" %}
|
{% if m.file_type == "photo" %}
|
||||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="photo">
|
<a href="{{ m.url }}" data-lightbox="photo">
|
||||||
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}/thumbnail" alt="" loading="lazy">
|
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
|
<a href="{{ m.url }}" data-lightbox="video">
|
||||||
<div class="vid-thumb">
|
<div class="vid-thumb">
|
||||||
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
|
<video src="{{ m.url }}#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
<span class="video-play">▶</span>
|
<span class="video-play">▶</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -426,8 +426,8 @@
|
|||||||
<div class="testimonial-text">{{ item.text }}</div>
|
<div class="testimonial-text">{{ item.text }}</div>
|
||||||
{% if item.image_path.is_some() || item.author_note.is_some() %}
|
{% if item.image_path.is_some() || item.author_note.is_some() %}
|
||||||
<div class="testimonial-footer">
|
<div class="testimonial-footer">
|
||||||
{% if item.image_path.is_some() %}
|
{% if let Some(image_url) = item.image_url.as_deref() %}
|
||||||
<img class="testimonial-avatar" src="/testimonial-image/{{ item.id.unwrap() }}" alt="">
|
<img class="testimonial-avatar" src="{{ image_url }}" alt="">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if let Some(note) = item.author_note.as_deref() %}
|
{% if let Some(note) = item.author_note.as_deref() %}
|
||||||
<span class="testimonial-note">{{ note }}</span>
|
<span class="testimonial-note">{{ note }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user