5 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov cec96cab1e Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m43s
2026-08-09 01:55:04 +01:00
Aleksandr Bogomiakov a9188f919b Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m45s
2026-08-09 01:32:10 +01:00
Aleksandr Bogomiakov 0edb8c12aa Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m46s
2026-08-09 01:10:03 +01:00
Aleksandr Bogomiakov 15c9528f47 Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m44s
2026-08-09 01:05:47 +01:00
Aleksandr Bogomiakov 9ee5048a43 Added Cloudflare R2 media storage support
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
2026-08-09 00:15:40 +01:00
18 changed files with 3327 additions and 481 deletions
Generated
+729 -49
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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"
@@ -16,7 +16,7 @@ serde_json = "1"
multer = "3" multer = "3"
futures = "0.3" futures = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
tokio = { version = "1", features = ["fs", "rt-multi-thread"] } tokio = { version = "1", features = ["fs", "process", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
base64 = "0.22" base64 = "0.22"
urlencoding = "2" urlencoding = "2"
@@ -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"] }
+5 -3
View File
@@ -1,16 +1,18 @@
FROM rust:1-slim AS builder FROM rust:1-slim-bookworm AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app 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 ffmpeg && 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 --from=builder /app/target/release/normalize_videos_to_r2 /usr/local/bin/normalize_videos_to_r2
COPY static /app/static COPY static /app/static
EXPOSE 3000 EXPOSE 3000
CMD ["web-petting"] CMD ["web-petting"]
+366 -81
View File
@@ -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,12 +341,36 @@ 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,
telegram_bot_token_configured: bool,
turnstile_secret_configured: bool,
oidc_client_secret_configured: bool,
vapid_private_key_configured: bool,
r2_secret_configured: bool,
push_subscribers: Vec<PushSubscriberItem>, push_subscribers: Vec<PushSubscriberItem>,
} }
fn setting_has_value(settings: &[Setting], key: &str) -> bool {
settings
.iter()
.find(|setting| setting.key == key)
.is_some_and(|setting| !setting.value.trim().is_empty())
}
fn preserved_secret_value(old_value: &str, submitted: &str, clear: bool) -> String {
if !submitted.trim().is_empty() {
submitted.trim().to_string()
} else if clear {
String::new()
} else {
old_value.to_string()
}
}
#[derive(Debug)] #[derive(Debug)]
struct PushSubscriberItem { struct PushSubscriberItem {
client_name: String, client_name: String,
@@ -419,12 +447,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 +496,37 @@ 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 delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
let url = storage
.public_url(&delivery.media_path, format!("/admin/uploads/{media_id}"))
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_fallback = format!("/admin/uploads/{media_id}/thumbnail");
let thumbnail_url = if storage.is_r2()
&& storage
.exists(&delivery.thumbnail_path)
.await
.map_err(crate::uploads::storage_error)?
{
storage
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
.await
.map_err(crate::uploads::storage_error)?
} else {
thumbnail_fallback
};
Ok(MediaView {
media,
url,
thumbnail_url,
})
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Auth Handlers // Auth Handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1254,15 +1328,33 @@ 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 telegram_bot_token_configured = setting_has_value(&settings, "telegram_bot_token");
let turnstile_secret_configured = setting_has_value(&settings, "turnstile_secret_key");
let oidc_client_secret_configured = setting_has_value(&settings, "oidc_client_secret");
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
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,
telegram_bot_token_configured,
turnstile_secret_configured,
oidc_client_secret_configured,
vapid_private_key_configured,
r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?, push_subscribers: load_push_subscribers(&db).await?,
} }
.render()?; .render()?;
@@ -1346,12 +1438,28 @@ 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)]
clear_telegram_bot_token: Option<String>,
#[serde(default)]
clear_turnstile_secret_key: Option<String>,
#[serde(default)]
clear_oidc_client_secret: Option<String>,
#[serde(default)]
clear_vapid_private_key: Option<String>,
#[serde(default)]
clear_r2_secret_access_key: Option<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> {
@@ -1369,28 +1477,83 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
.map(|setting| setting.value.as_str()) .map(|setting| setting.value.as_str())
.unwrap_or("") .unwrap_or("")
}; };
let telegram_bot_token = preserved_secret_value(
old_value("telegram_bot_token"),
&form.telegram_bot_token,
form.clear_telegram_bot_token.is_some(),
);
let turnstile_secret_key = preserved_secret_value(
old_value("turnstile_secret_key"),
&form.turnstile_secret_key,
form.clear_turnstile_secret_key.is_some(),
);
let oidc_client_secret = preserved_secret_value(
old_value("oidc_client_secret"),
&form.oidc_client_secret,
form.clear_oidc_client_secret.is_some(),
);
let vapid_private_key = preserved_secret_value(
old_value("vapid_private_key"),
&form.vapid_private_key,
form.clear_vapid_private_key.is_some(),
);
let r2_secret_access_key = preserved_secret_value(
old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY),
&form.r2_secret_access_key,
form.clear_r2_secret_access_key.is_some(),
);
let had_vapid_keys = !old_value("vapid_public_key").trim().is_empty() let had_vapid_keys = !old_value("vapid_public_key").trim().is_empty()
|| !old_value("vapid_private_key").trim().is_empty(); || !old_value("vapid_private_key").trim().is_empty();
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() != vapid_private_key.trim());
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", telegram_bot_token),
("contact_info", form.contact_info), ("contact_info", form.contact_info),
("pricing_info", form.pricing_info), ("pricing_info", form.pricing_info),
("timezone", form.timezone), ("timezone", form.timezone),
("site_domain", form.site_domain), ("site_domain", form.site_domain),
("seo_keywords", form.seo_keywords), ("seo_keywords", form.seo_keywords),
("turnstile_site_key", form.turnstile_site_key), ("turnstile_site_key", form.turnstile_site_key),
("turnstile_secret_key", form.turnstile_secret_key), ("turnstile_secret_key", turnstile_secret_key),
("oidc_issuer_url", form.oidc_issuer_url), ("oidc_issuer_url", form.oidc_issuer_url),
("oidc_client_id", form.oidc_client_id), ("oidc_client_id", form.oidc_client_id),
("oidc_client_secret", form.oidc_client_secret), ("oidc_client_secret", oidc_client_secret),
("oidc_allowed_groups", form.oidc_allowed_groups), ("oidc_allowed_groups", form.oidc_allowed_groups),
("vapid_public_key", form.vapid_public_key), ("vapid_public_key", form.vapid_public_key),
("vapid_private_key", form.vapid_private_key), ("vapid_private_key", 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 +1625,33 @@ 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 telegram_bot_token_configured = setting_has_value(&settings, "telegram_bot_token");
let turnstile_secret_configured = setting_has_value(&settings, "turnstile_secret_key");
let oidc_client_secret_configured = setting_has_value(&settings, "oidc_client_secret");
let vapid_private_key_configured = setting_has_value(&settings, "vapid_private_key");
let r2_secret_configured =
setting_has_value(&settings, crate::uploads::R2_SECRET_ACCESS_KEY_KEY);
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,
telegram_bot_token_configured,
turnstile_secret_configured,
oidc_client_secret_configured,
vapid_private_key_configured,
r2_secret_configured,
push_subscribers: load_push_subscribers(&db).await?, push_subscribers: load_push_subscribers(&db).await?,
} }
.render()?; .render()?;
@@ -1964,6 +2145,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 +2157,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 +2343,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 +2458,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 +2511,14 @@ 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 original_path =
crate::uploads::write_db_file(&path, &data) crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}"));
let path = crate::uploads::normalized_video_db_path(&original_path);
crate::uploads::write_compact_video(&storage, &path, &ext, &data)
.await .await
.map_err(|e| cot::Error::internal(e.to_string()))?; .map_err(crate::uploads::storage_error)?;
path path
}; };
@@ -2382,11 +2569,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 +2585,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
@@ -2420,14 +2606,44 @@ async fn serve_upload_thumbnail(
return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response(); return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response();
} }
let media = match query!(Media, $id == media_id).get(&db).await? { let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media) if media.status == "active" && media.file_type == "photo" => media, Some(media) if media.status == "active" => 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 = match crate::uploads::ensure_media_delivery_paths(
&storage,
&media.file_type,
&media.file_path,
)
.await
{
Ok(paths) => paths.thumbnail_path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create media thumbnail");
if media.file_type == "photo" {
media.file_path.clone()
} else {
return Html::new("404").into_response();
}
}
};
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 +2651,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,28 +2686,31 @@ 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("") { let display_path =
"jpg" | "jpeg" => "image/jpeg", crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
"png" => "image/png", if storage.is_r2() {
"heic" | "heif" => "image/heic", let url = storage
"webp" => "image/webp", .public_url(&display_path, String::new())
"mp4" => "video/mp4", .await
"mov" => "video/quicktime", .map_err(crate::uploads::storage_error)?;
"avi" => "video/x-msvideo", return Redirect::new(url).into_response();
"mkv" => "video/x-matroska", }
"webm" => "video/webm",
_ => "application/octet-stream", match crate::uploads::ranged_local_file_response(
}; &display_path,
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await crate::uploads::content_type_for_path(&display_path),
} { range.as_deref(),
)
.await
{
Ok(response) => Ok(response), Ok(response) => Ok(response),
Err(err) => { Err(err) => {
tracing::warn!( tracing::warn!(
target: "uploads", target: "uploads",
media_id, media_id,
db_path = %media.file_path, db_path = %display_path,
resolved_path = %crate::uploads::resolved_display_path(&media.file_path), resolved_path = %crate::uploads::resolved_display_path(&display_path),
error = %err, error = %err,
"uploaded file is missing or unreadable" "uploaded file is missing or unreadable"
); );
@@ -2510,7 +2729,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 +2785,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 +2818,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 +2865,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 +2960,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 +3015,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 +3061,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",
@@ -2981,3 +3253,16 @@ pub fn admin_router() -> Router {
Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"), Route::with_handler_and_name("/settings/save", save_settings, "admin-settings-save"),
]) ])
} }
#[cfg(test)]
mod tests {
use super::preserved_secret_value;
#[test]
fn keeps_masks_and_updates_secrets_consistently() {
assert_eq!(preserved_secret_value("stored", "", false), "stored");
assert_eq!(preserved_secret_value("stored", " new ", false), "new");
assert_eq!(preserved_secret_value("stored", "", true), "");
assert_eq!(preserved_secret_value("stored", "new", true), "new");
}
}
+171
View File
@@ -0,0 +1,171 @@
#[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 add_video_derivatives(
sources: &mut BTreeMap<String, bool>,
db_path: &str,
required: bool,
) -> bool {
if !uploads::supports_video_preview(db_path) {
return true;
}
match uploads::ensure_video_thumbnail(&uploads::Storage::Local, 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 video derivatives 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);
match media.file_type.as_str() {
"photo" if !add_thumbnail(&mut sources, &media.file_path, required).await => {
preparation_failed = true;
}
"video" if !add_video_derivatives(&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);
}
}
+249
View File
@@ -0,0 +1,249 @@
#[allow(dead_code)]
#[path = "../models.rs"]
mod models;
#[allow(dead_code)]
#[path = "../uploads.rs"]
mod uploads;
use std::path::{Path, PathBuf};
use cot::db::{Database, Model};
use models::Media;
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 local_file_exists(path: &Path) -> Result<bool, std::io::Error> {
match tokio::fs::metadata(path).await {
Ok(metadata) => Ok(metadata.is_file() && metadata.len() > 0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
async fn valid_local_video(path: &Path, media_id: i64) -> Result<bool, std::io::Error> {
if !local_file_exists(path).await? {
return Ok(false);
}
match uploads::validate_video_file(path).await {
Ok(()) => Ok(true),
Err(error) => {
eprintln!(
"Ignoring incomplete local output for media {media_id}: {} ({error})",
path.display()
);
Ok(false)
}
}
}
async fn download_to_workspace(
r2: &uploads::Storage,
db_path: &str,
workspace: &Path,
filename: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let destination = workspace.join(filename);
r2.download_to_path(db_path, &destination).await?;
Ok(destination)
}
async fn prepare_missing_objects(
r2: &uploads::Storage,
media_id: i64,
original_db_path: &str,
target_db_path: &str,
target_thumbnail_db_path: &str,
target_in_r2: bool,
thumbnail_in_r2: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace =
std::env::temp_dir().join(format!("web-petting-normalize-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir(&workspace).await?;
let result = async {
let original_path = uploads::resolve_db_path(original_db_path);
let local_target_path = uploads::resolve_db_path(target_db_path);
let generated_video_path = workspace.join("video.web.mp4");
let generated_thumbnail_path = workspace.join("video.web.thumb.jpg");
let mut prepared_video: Option<PathBuf> = None;
let mut prepared_thumbnail: Option<PathBuf> = None;
if !target_in_r2 {
if valid_local_video(&local_target_path, media_id).await? {
println!("Reusing valid compact video on PVC for media {media_id}");
prepared_video = Some(local_target_path.clone());
} else {
let source = if original_db_path != target_db_path
&& local_file_exists(&original_path).await?
{
original_path.clone()
} else if original_db_path != target_db_path && r2.exists(original_db_path).await? {
println!("Downloading source from R2 for media {media_id}");
let extension = original_db_path.rsplit('.').next().unwrap_or("mov");
download_to_workspace(
r2,
original_db_path,
&workspace,
&format!("source.{extension}"),
)
.await?
} else {
return Err(format!(
"source is absent from both PVC and R2: {}",
original_path.display()
)
.into());
};
println!("Converting media {media_id}: {original_db_path}");
uploads::create_compact_video_files(
&source,
&generated_video_path,
&generated_thumbnail_path,
)
.await?;
prepared_video = Some(generated_video_path.clone());
prepared_thumbnail = Some(generated_thumbnail_path.clone());
}
let video_path = prepared_video
.as_ref()
.ok_or("compact video was not prepared")?;
uploads::validate_video_file(video_path).await?;
r2.upload_local_copy(target_db_path, video_path).await?;
println!("Uploaded compact video: {target_db_path}");
}
if !thumbnail_in_r2 {
let video_for_preview = if let Some(video_path) = prepared_video.as_ref() {
video_path.clone()
} else if valid_local_video(&local_target_path, media_id).await? {
local_target_path
} else {
println!("Downloading compact video from R2 for media {media_id}");
let downloaded =
download_to_workspace(r2, target_db_path, &workspace, "existing-video.web.mp4")
.await?;
uploads::validate_video_file(&downloaded).await?;
downloaded
};
let thumbnail_path = if let Some(thumbnail_path) = prepared_thumbnail.as_ref() {
thumbnail_path.clone()
} else {
uploads::create_video_thumbnail_file(&video_for_preview, &generated_thumbnail_path)
.await?;
generated_thumbnail_path
};
if !local_file_exists(&thumbnail_path).await? {
return Err(format!(
"video preview was not created: {}",
thumbnail_path.display()
)
.into());
}
r2.upload_local_copy(target_thumbnail_db_path, &thumbnail_path)
.await?;
println!("Uploaded video preview: {target_thumbnail_db_path}");
}
Ok(())
}
.await;
let _ = tokio::fs::remove_dir_all(&workspace).await;
result
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::new(database_url()).await?;
let r2 = uploads::Storage::load_configured_r2(&db).await?;
let mut converted = 0usize;
let mut already_normalized = 0usize;
let mut missing_archived = 0usize;
let mut failed = 0usize;
for mut media in Media::objects().all(&db).await? {
if media.file_type != "video" {
continue;
}
let media_id = media.id.unwrap();
let original_db_path = media.file_path.clone();
let target_db_path = uploads::normalized_video_db_path(&original_db_path);
let target_thumbnail_db_path = uploads::thumbnail_db_path(&target_db_path);
let mut target_in_r2 = r2.exists(&target_db_path).await?;
let mut thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
if !target_in_r2 || !thumbnail_in_r2 {
if let Err(error) = prepare_missing_objects(
&r2,
media_id,
&original_db_path,
&target_db_path,
&target_thumbnail_db_path,
target_in_r2,
thumbnail_in_r2,
)
.await
{
eprintln!("Could not prepare media {media_id}: {error}");
if media.status == "active" {
failed += 1;
} else {
missing_archived += 1;
}
continue;
}
target_in_r2 = r2.exists(&target_db_path).await?;
thumbnail_in_r2 = r2.exists(&target_thumbnail_db_path).await?;
}
if !target_in_r2 || !thumbnail_in_r2 {
eprintln!("Could not verify normalized R2 objects for media {media_id}");
failed += 1;
continue;
}
if original_db_path != target_db_path {
// Both replacements are verified before old R2 keys are removed.
// Original files on the PVC are deliberately left untouched.
r2.remove(&original_db_path).await?;
let original_thumbnail = uploads::thumbnail_db_path(&original_db_path);
if original_thumbnail != target_thumbnail_db_path {
r2.remove(&original_thumbnail).await?;
}
media.file_path = target_db_path.clone();
media.save(&db).await?;
converted += 1;
println!("Normalized media {media_id}: {target_db_path}");
} else {
already_normalized += 1;
}
}
db.close().await?;
println!(
"Video normalization summary: converted={converted}, already_normalized={already_normalized}, missing_archived={missing_archived}, failed={failed}"
);
println!("Original PVC files were not changed or deleted.");
if failed > 0 {
return Err("video normalization 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!("Video normalization failed: {error}");
std::process::exit(1);
}
}
+75
View File
@@ -131,6 +131,16 @@ pub struct Translations {
pub settings_save: &'static str, pub settings_save: &'static str,
pub settings_saved: &'static str, pub settings_saved: &'static str,
pub settings_empty: &'static str, pub settings_empty: &'static str,
pub settings_intro: &'static str,
pub settings_section_general_help: &'static str,
pub settings_section_notifications_help: &'static str,
pub settings_section_captcha_help: &'static str,
pub settings_section_oidc_help: &'static str,
pub settings_secret_saved: &'static str,
pub settings_secret_not_set: &'static str,
pub settings_secret_clear: &'static str,
pub settings_status_enabled: &'static str,
pub settings_status_disabled: &'static str,
pub settings_telegram_bot_token: &'static str, pub settings_telegram_bot_token: &'static str,
pub settings_telegram_chat_id: &'static str, pub settings_telegram_chat_id: &'static str,
pub settings_contact_info: &'static str, pub settings_contact_info: &'static str,
@@ -151,6 +161,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,
@@ -293,6 +313,11 @@ pub struct Translations {
pub media_delete: &'static str, pub media_delete: &'static str,
pub media_delete_confirm: &'static str, pub media_delete_confirm: &'static str,
pub media_all_clients: &'static str, pub media_all_clients: &'static str,
pub media_files_selected: &'static str,
pub media_upload_sending: &'static str,
pub media_upload_processing: &'static str,
pub media_upload_done: &'static str,
pub media_upload_connection_error: &'static str,
// Client portal // Client portal
pub portal_title: &'static str, pub portal_title: &'static str,
@@ -391,6 +416,16 @@ static RU: Translations = Translations {
settings_save: "Сохранить", settings_save: "Сохранить",
settings_saved: "Сохранено!", settings_saved: "Сохранено!",
settings_empty: "Настройки не заданы.", settings_empty: "Настройки не заданы.",
settings_intro: "Параметры сайта, интеграций, хранения медиа и способов входа.",
settings_section_general_help: "Публичные данные сайта и параметры, используемые при формировании ссылок и дат.",
settings_section_notifications_help: "Telegram для администратора и браузерные push-уведомления для клиентов.",
settings_section_captcha_help: "Ключи Cloudflare Turnstile для защиты публичных форм от автоматических отправок.",
settings_section_oidc_help: "Настройте доступ администраторов по паролю и через внешний OIDC-провайдер.",
settings_secret_saved: "Секрет сохранён. Оставьте поле пустым, чтобы не менять его.",
settings_secret_not_set: "Секрет пока не задан.",
settings_secret_clear: "Удалить сохранённый секрет",
settings_status_enabled: "Включено",
settings_status_disabled: "Выключено",
settings_telegram_bot_token: "Токен Telegram бота", settings_telegram_bot_token: "Токен Telegram бота",
settings_telegram_chat_id: "Chat ID для уведомлений", settings_telegram_chat_id: "Chat ID для уведомлений",
settings_contact_info: "Контактная информация (отображается на лендинге)", settings_contact_info: "Контактная информация (отображается на лендинге)",
@@ -411,6 +446,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 и normalize_videos_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 — публичный ключ",
@@ -443,6 +488,11 @@ static RU: Translations = Translations {
media_delete: "Удалить", media_delete: "Удалить",
media_delete_confirm: "Удалить этот файл?", media_delete_confirm: "Удалить этот файл?",
media_all_clients: "Все клиенты", media_all_clients: "Все клиенты",
media_files_selected: "Выбрано файлов",
media_upload_sending: "Загрузка на сервер...",
media_upload_processing: "Конвертация и загрузка в R2...",
media_upload_done: "Готово — обновляем медиагалерею...",
media_upload_connection_error: "Ошибка соединения",
portal_title: "Визиты", portal_title: "Визиты",
portal_upcoming: "Предстоящие визиты", portal_upcoming: "Предстоящие визиты",
@@ -641,6 +691,16 @@ static EN: Translations = Translations {
settings_save: "Save", settings_save: "Save",
settings_saved: "Saved!", settings_saved: "Saved!",
settings_empty: "No settings configured.", settings_empty: "No settings configured.",
settings_intro: "Site, integration, media storage, and sign-in settings.",
settings_section_general_help: "Public site details and values used to build links and display dates.",
settings_section_notifications_help: "Telegram alerts for administrators and browser push notifications for clients.",
settings_section_captcha_help: "Cloudflare Turnstile keys used to protect public forms from automated submissions.",
settings_section_oidc_help: "Configure administrator access with passwords and an external OIDC provider.",
settings_secret_saved: "A secret is stored. Leave this field blank to keep it unchanged.",
settings_secret_not_set: "No secret is currently stored.",
settings_secret_clear: "Remove the stored secret",
settings_status_enabled: "Enabled",
settings_status_disabled: "Disabled",
settings_telegram_bot_token: "Telegram Bot Token", settings_telegram_bot_token: "Telegram Bot Token",
settings_telegram_chat_id: "Notification Chat ID", settings_telegram_chat_id: "Notification Chat ID",
settings_contact_info: "Contact info (shown on landing page)", settings_contact_info: "Contact info (shown on landing page)",
@@ -661,6 +721,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 and normalize_videos_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",
@@ -693,6 +763,11 @@ static EN: Translations = Translations {
media_delete: "Delete", media_delete: "Delete",
media_delete_confirm: "Delete this file?", media_delete_confirm: "Delete this file?",
media_all_clients: "All clients", media_all_clients: "All clients",
media_files_selected: "Files selected",
media_upload_sending: "Uploading to the server...",
media_upload_processing: "Converting and uploading to R2...",
media_upload_done: "Done — refreshing the media gallery...",
media_upload_connection_error: "Connection error",
portal_title: "Visits", portal_title: "Visits",
portal_upcoming: "Upcoming visits", portal_upcoming: "Upcoming visits",
-2
View File
@@ -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,
+197 -66
View File
@@ -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,57 @@ 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 delivery = crate::uploads::media_delivery_paths(&media.file_type, &media.file_path);
let url = storage
.public_url(
&delivery.media_path,
format!("/client/{client_token}/media/{media_id}"),
)
.await
.map_err(crate::uploads::storage_error)?;
let thumbnail_fallback = format!("/client/{client_token}/media/{media_id}/thumbnail");
let thumbnail_url = if storage.is_r2()
&& storage
.exists(&delivery.thumbnail_path)
.await
.map_err(crate::uploads::storage_error)?
{
storage
.public_url(&delivery.thumbnail_path, thumbnail_fallback)
.await
.map_err(crate::uploads::storage_error)?
} else {
thumbnail_fallback
};
Ok(PortalMediaView {
media,
url,
thumbnail_url,
})
} }
#[derive(Debug)] #[derive(Debug)]
@@ -334,36 +420,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 +435,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,28 +859,31 @@ 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("") { let display_path =
"jpg" | "jpeg" => "image/jpeg", crate::uploads::media_delivery_paths(&media.file_type, &media.file_path).media_path;
"png" => "image/png", if storage.is_r2() {
"heic" | "heif" => "image/heic", let url = storage
"webp" => "image/webp", .public_url(&display_path, String::new())
"mp4" => "video/mp4", .await
"mov" => "video/quicktime", .map_err(crate::uploads::storage_error)?;
"avi" => "video/x-msvideo", return Redirect::new(url).into_response();
"mkv" => "video/x-matroska", }
"webm" => "video/webm",
_ => "application/octet-stream", match crate::uploads::ranged_local_file_response(
}; &display_path,
crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await crate::uploads::content_type_for_path(&display_path),
} { range.as_deref(),
)
.await
{
Ok(response) => Ok(response), Ok(response) => Ok(response),
Err(err) => { Err(err) => {
tracing::warn!( tracing::warn!(
target: "uploads", target: "uploads",
media_id, media_id,
db_path = %media.file_path, db_path = %display_path,
resolved_path = %crate::uploads::resolved_display_path(&media.file_path), resolved_path = %crate::uploads::resolved_display_path(&display_path),
error = %err, error = %err,
"portal media file is missing or unreadable" "portal media file is missing or unreadable"
); );
@@ -801,8 +904,7 @@ async fn portal_media_thumbnail(
let media = match query!(Media, $id == media_id).get(&db).await? { let media = match query!(Media, $id == media_id).get(&db).await? {
Some(media) Some(media)
if media.client_id.primary_key().unwrap() == client.id.unwrap() if media.client_id.primary_key().unwrap() == client.id.unwrap()
&& media.status == "active" && media.status == "active" =>
&& media.file_type == "photo" =>
{ {
media media
} }
@@ -815,11 +917,38 @@ 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 = match crate::uploads::ensure_media_delivery_paths(
let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None) &storage,
.await &media.file_type,
.map_err(|error| cot::Error::internal(error.to_string()))?; &media.file_path,
)
.await
{
Ok(paths) => paths.thumbnail_path,
Err(error) => {
tracing::warn!(media_id, %error, "failed to create portal media thumbnail");
if media.file_type == "photo" {
media.file_path.clone()
} else {
return Html::new("404").into_response();
}
}
};
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 +956,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 +975,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",
+848 -32
View File
@@ -1,15 +1,403 @@
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 tokio::process::Command;
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;
pub const VIDEO_PREVIEW_FRAME_COUNT: usize = 4;
const VIDEO_PREVIEW_FRAME_WIDTH: u32 = 320;
const VIDEO_PREVIEW_FRAME_HEIGHT: u32 = 240;
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())
}
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
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 mut reader = response.body.into_async_read();
let mut file = tokio::fs::File::create(destination)
.await
.map_err(|error| StorageError::new("failed to create temporary media file", error))?;
tokio::io::copy(&mut reader, &mut file)
.await
.map_err(|error| StorageError::new("failed to stream R2 object to disk", error))?;
Ok(())
}
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,
}
}
pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> {
match self {
Self::Local => {
tokio::fs::copy(resolve_db_path(db_path), destination)
.await
.map_err(|error| {
StorageError::new("failed to copy local media to temporary file", error)
})?;
Ok(())
}
Self::R2(storage) => storage.download_to_path(db_path, destination).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 metadata = tokio::fs::metadata(local_path).await.map_err(|error| {
StorageError::new(
&format!(
"failed to inspect local migration file {}",
local_path.display()
),
error,
)
})?;
if !metadata.is_file() || metadata.len() == 0 {
return Err(StorageError::new(
"failed to open local file for migration",
format!("{} is not a non-empty file", local_path.display()),
));
}
let body = ByteStream::from_path(local_path).await.map_err(|error| {
StorageError::new(
&format!(
"failed to open local file for migration {}",
local_path.display()
),
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 +432,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 +446,46 @@ pub fn thumbnail_db_path(db_path: &str) -> String {
} }
} }
pub fn normalized_video_db_path(db_path: &str) -> String {
match db_path.rsplit_once('.') {
Some((stem, _)) if stem.ends_with(".web") => format!("{stem}.mp4"),
Some((stem, _)) => format!("{stem}.web.mp4"),
None => format!("{db_path}.web.mp4"),
}
}
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 supports_video_preview(db_path: &str) -> bool {
matches!(
db_path
.rsplit('.')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"mp4" | "mov" | "avi" | "mkv" | "webm"
)
}
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,24 +499,351 @@ 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) 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)
}
#[derive(Debug)]
pub struct MediaDeliveryPaths {
pub media_path: String,
pub thumbnail_path: String,
}
async fn create_video_workspace() -> StorageResult<PathBuf> {
let path = std::env::temp_dir().join(format!(
"web-petting-video-preview-{}",
uuid::Uuid::new_v4()
));
tokio::fs::create_dir(&path)
.await
.map_err(|error| StorageError::new("failed to create video workspace", error))?;
Ok(path)
}
async fn run_media_command(command: &mut Command, context: &str) -> StorageResult<()> {
let output = command
.output()
.await
.map_err(|error| StorageError::new(context, error))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(StorageError::new(
context,
stderr
.trim()
.lines()
.last()
.unwrap_or("unknown ffmpeg error"),
))
}
fn video_transcode_command(source: &Path, destination: &Path, include_audio: bool) -> Command {
let mut command = Command::new("ffmpeg");
command
.args([
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ignore_unknown",
"-i",
])
.arg(source)
.args(["-map", "0:v:0"]);
if include_audio {
command.args(["-map", "0:a:0?"]);
} else {
command.arg("-an");
}
command
.args(["-map_metadata", "-1", "-map_chapters", "-1", "-sn", "-dn"])
.args([
"-vf",
"scale=w='min(1280,iw)':h='min(1280,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"26",
"-pix_fmt",
"yuv420p",
"-tag:v",
"avc1",
"-c:a",
"aac",
"-b:a",
"96k",
"-movflags",
"+faststart",
"-threads",
"2",
])
.arg(destination);
command
}
async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> {
let mut with_audio = video_transcode_command(source, destination, true);
match run_media_command(&mut with_audio, "failed to transcode video with ffmpeg").await {
Ok(()) => Ok(()),
Err(audio_error) => {
// Some iPhone MOV files contain a second, audio-like service stream
// with no decoder. Preserve audio when possible, but keep the video
// usable by retrying without that stream.
let mut video_only = video_transcode_command(source, destination, false);
run_media_command(
&mut video_only,
&format!("{audio_error}; video-only fallback also failed"),
)
.await
}
}
}
async fn video_duration(source: &Path) -> StorageResult<f64> {
let mut command = Command::new("ffprobe");
command
.args([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
])
.arg(source);
let output = command
.output()
.await
.map_err(|error| StorageError::new("failed to inspect video with ffprobe", error))?;
if !output.status.success() {
return Err(StorageError::new(
"failed to inspect video with ffprobe",
String::from_utf8_lossy(&output.stderr).trim(),
));
}
let duration = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<f64>()
.map_err(|error| StorageError::new("failed to parse video duration", error))?;
if !duration.is_finite() || duration <= 0.0 {
return Err(StorageError::new(
"failed to inspect video duration",
"duration is zero or invalid",
));
}
Ok(duration)
}
#[allow(dead_code)]
pub async fn validate_video_file(source: &Path) -> StorageResult<()> {
video_duration(source).await.map(|_| ())
}
async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult<()> {
let duration = video_duration(source).await?;
let frame_times = [0.08, 0.34, 0.60, 0.86].map(|position| duration * position);
let mut command = Command::new("ffmpeg");
command.args(["-hide_banner", "-loglevel", "error", "-y"]);
for timestamp in frame_times {
command
.arg("-ss")
.arg(format!("{timestamp:.3}"))
.arg("-i")
.arg(source);
}
let frame_filter = format!(
"scale={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={VIDEO_PREVIEW_FRAME_WIDTH}:{VIDEO_PREVIEW_FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
);
let filter = format!(
"[0:v]{frame_filter}[v0];[1:v]{frame_filter}[v1];[2:v]{frame_filter}[v2];[3:v]{frame_filter}[v3];[v0][v1][v2][v3]hstack=inputs={VIDEO_PREVIEW_FRAME_COUNT}[out]"
);
command
.arg("-filter_complex")
.arg(filter)
.args(["-map", "[out]", "-frames:v", "1", "-q:v", "4"])
.arg(destination);
run_media_command(&mut command, "failed to create video preview with ffmpeg").await
}
pub async fn create_video_thumbnail_file(source: &Path, destination: &Path) -> StorageResult<()> {
create_video_sprite(source, destination).await
}
/// Create the compact browser MP4 and its four-frame JPEG sprite on disk.
/// The source path is only read and is never changed or removed.
pub async fn create_compact_video_files(
source: &Path,
video_destination: &Path,
thumbnail_destination: &Path,
) -> StorageResult<()> {
let result = async {
transcode_video_for_browser(source, video_destination).await?;
create_video_sprite(video_destination, thumbnail_destination).await
}
.await;
if result.is_err() {
let _ = tokio::fs::remove_file(video_destination).await;
let _ = tokio::fs::remove_file(thumbnail_destination).await;
}
result
}
/// Convert an incoming upload and store only the compact browser MP4 and its
/// JPEG sprite. The original upload bytes are never written to storage.
pub async fn write_compact_video(
storage: &Storage,
db_path: &str,
source_extension: &str,
source_data: &[u8],
) -> StorageResult<()> {
let workspace = create_video_workspace().await?;
let source = workspace.join(format!("source.{source_extension}"));
let compact_video = workspace.join("video.mp4");
let thumbnail = workspace.join("preview.jpg");
let result = async {
tokio::fs::write(&source, source_data)
.await
.map_err(|error| StorageError::new("failed to write temporary video", error))?;
create_compact_video_files(&source, &compact_video, &thumbnail).await?;
let video_data = tokio::fs::read(&compact_video)
.await
.map_err(|error| StorageError::new("failed to read transcoded video", error))?;
let thumbnail_data = tokio::fs::read(&thumbnail)
.await
.map_err(|error| StorageError::new("failed to read video preview", error))?;
let thumbnail_path = thumbnail_db_path(db_path);
storage.write(db_path, &video_data).await?;
storage.write(&thumbnail_path, &thumbnail_data).await?;
Ok(())
}
.await;
let _ = tokio::fs::remove_dir_all(&workspace).await;
result
}
/// Create and persist a missing video sprite. In R2 mode the compact source is
/// downloaded only once; subsequent page loads reuse the generated JPEG.
pub async fn ensure_video_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 workspace = create_video_workspace().await?;
let extension = db_path.rsplit('.').next().unwrap_or("mp4");
let source = workspace.join(format!("source.{extension}"));
let thumbnail = workspace.join("preview.jpg");
let result = async {
storage.download_to_path(db_path, &source).await?;
create_video_thumbnail_file(&source, &thumbnail).await?;
let data = tokio::fs::read(&thumbnail)
.await
.map_err(|error| StorageError::new("failed to read video preview", error))?;
storage.write(&thumbnail_path, &data).await?;
Ok(thumbnail_path.clone())
}
.await;
let _ = tokio::fs::remove_dir_all(&workspace).await;
result
}
pub async fn ensure_media_delivery_paths(
storage: &Storage,
file_type: &str,
db_path: &str,
) -> StorageResult<MediaDeliveryPaths> {
if file_type == "video" && supports_video_preview(db_path) {
let thumbnail_path = ensure_video_thumbnail(storage, db_path).await?;
return Ok(MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
});
}
let thumbnail_path = if file_type == "photo" && supports_thumbnail(db_path) {
ensure_thumbnail(storage, db_path).await?
} else {
db_path.to_string()
};
Ok(MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
})
}
pub fn media_delivery_paths(file_type: &str, db_path: &str) -> MediaDeliveryPaths {
let thumbnail_path = if (file_type == "photo" && supports_thumbnail(db_path))
|| (file_type == "video" && supports_video_preview(db_path))
{
thumbnail_db_path(db_path)
} else {
db_path.to_string()
};
MediaDeliveryPaths {
media_path: db_path.to_string(),
thumbnail_path,
}
}
enum ByteRange { enum ByteRange {
Full, Full,
Partial { start: u64, end: u64 }, Partial { start: u64, end: u64 },
@@ -155,8 +899,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 +961,33 @@ 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, normalized_video_db_path, object_key, parse_byte_range,
supports_video_preview, thumbnail_db_path,
};
#[test]
fn creates_stable_normalized_video_paths() {
assert_eq!(
normalized_video_db_path("uploads/1/report.mov"),
"uploads/1/report.web.mp4"
);
assert_eq!(
normalized_video_db_path("uploads/1/report.web.mp4"),
"uploads/1/report.web.mp4"
);
assert_eq!(
thumbnail_db_path("uploads/1/report.web.mp4"),
"uploads/1/report.web.thumb.jpg"
);
assert!(supports_video_preview("report.MOV"));
}
#[test] #[test]
fn parses_byte_ranges() { fn parses_byte_ranges() {
@@ -244,4 +1008,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"));
}
} }
+14 -12
View File
@@ -27,13 +27,17 @@
{% 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"> <span class="photo-thumb media-loading-frame is-loading">
<img src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load>
</span>
</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 media-loading-frame{% if !item.media.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
<video src="/admin/uploads/{{ item.media.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video> {% if !item.media.thumbnail_url.is_empty() %}
<img class="video-preview-sprite" src="{{ item.media.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
{% endif %}
<span class="video-play"></span> <span class="video-play"></span>
</div> </div>
</a> </a>
@@ -88,18 +92,16 @@
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.media-card .photo-thumb {
width: 100%;
height: 160px;
}
.media-card .video-thumb { .media-card .video-thumb {
position: relative; position: relative;
width: 100%; width: 100%;
height: 160px; height: 160px;
background: #111; background: #111;
} }
.media-card .video-thumb video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.media-card .video-play { .media-card .video-play {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -132,7 +134,7 @@
.media-grid { .media-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
} }
.media-card img, .media-card .video-thumb { .media-card img, .media-card .photo-thumb, .media-card .video-thumb {
height: 120px; height: 120px;
} }
} }
+72 -5
View File
@@ -21,6 +21,7 @@
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required> <input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
</div> </div>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p> <p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
<div id="uploadQueue" class="upload-queue"></div>
</div> </div>
<div class="field"> <div class="field">
@@ -49,6 +50,7 @@
var form = document.getElementById('uploadForm'); var form = document.getElementById('uploadForm');
var filesInput = document.getElementById('uploadFiles'); var filesInput = document.getElementById('uploadFiles');
var fileCount = document.getElementById('fileCount'); var fileCount = document.getElementById('fileCount');
var queue = document.getElementById('uploadQueue');
var progress = document.getElementById('uploadProgress'); var progress = document.getElementById('uploadProgress');
var bar = document.getElementById('uploadBar'); var bar = document.getElementById('uploadBar');
var percent = document.getElementById('uploadPercent'); var percent = document.getElementById('uploadPercent');
@@ -57,9 +59,41 @@
filesInput.addEventListener('change', function() { filesInput.addEventListener('change', function() {
var n = this.files.length; var n = this.files.length;
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : ''; fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
queue.replaceChildren();
Array.from(this.files).forEach(function(file) {
var item = document.createElement('div');
item.className = 'upload-queue-item';
var icon = document.createElement('span');
icon.className = 'upload-queue-icon';
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
var details = document.createElement('div');
var name = document.createElement('div');
name.className = 'upload-queue-name';
name.textContent = file.name;
var state = document.createElement('div');
state.className = 'upload-queue-state';
state.textContent = '0%';
var track = document.createElement('div');
track.className = 'upload-queue-track';
var itemBar = document.createElement('div');
itemBar.className = 'upload-queue-bar';
track.appendChild(itemBar);
details.append(name, state, track);
item.append(icon, details);
queue.appendChild(item);
});
}); });
function updateQueue(state, progressValue, processing) {
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
item.querySelector('.upload-queue-state').textContent = state;
var itemBar = item.querySelector('.upload-queue-bar');
itemBar.classList.toggle('is-processing', processing);
if (!processing) itemBar.style.width = progressValue + '%';
});
}
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
if (!filesInput.files.length) return; if (!filesInput.files.length) return;
@@ -69,7 +103,9 @@
progress.style.display = 'block'; progress.style.display = 'block';
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Загрузка...'; submitBtn.textContent = '{{ t.media_upload_sending }}';
statusText.textContent = '{{ t.media_upload_sending }}';
bar.classList.remove('is-processing');
bar.style.width = '0%'; bar.style.width = '0%';
percent.textContent = '0%'; percent.textContent = '0%';
@@ -78,24 +114,42 @@
var pct = Math.round(ev.loaded / ev.total * 100); var pct = Math.round(ev.loaded / ev.total * 100);
bar.style.width = pct + '%'; bar.style.width = pct + '%';
percent.textContent = pct + '%'; percent.textContent = pct + '%';
if (pct === 100) statusText.textContent = 'Обработка...'; updateQueue(pct + '%', pct, false);
if (pct === 100) {
statusText.textContent = '{{ t.media_upload_processing }}';
percent.textContent = '•••';
bar.classList.add('is-processing');
updateQueue('{{ t.media_upload_processing }}', 100, true);
}
});
xhr.upload.addEventListener('load', function() {
statusText.textContent = '{{ t.media_upload_processing }}';
percent.textContent = '•••';
bar.classList.add('is-processing');
updateQueue('{{ t.media_upload_processing }}', 100, true);
}); });
xhr.addEventListener('load', function() { xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) { if (xhr.status >= 200 && xhr.status < 400) {
bar.style.width = '100%'; bar.style.width = '100%';
bar.classList.remove('is-processing');
percent.textContent = '100%'; percent.textContent = '100%';
statusText.textContent = 'Готово!'; statusText.textContent = '{{ t.media_upload_done }}';
updateQueue('{{ t.media_upload_done }}', 100, false);
setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300); setTimeout(function() { window.location.href = xhr.responseURL || '/admin/media'; }, 300);
} else { } else {
bar.classList.remove('is-processing');
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')'; statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
updateQueue('Ошибка загрузки', 0, false);
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}'; submitBtn.textContent = '{{ t.media_upload }}';
} }
}); });
xhr.addEventListener('error', function() { xhr.addEventListener('error', function() {
statusText.textContent = 'Ошибка соединения'; bar.classList.remove('is-processing');
statusText.textContent = '{{ t.media_upload_connection_error }}';
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}'; submitBtn.textContent = '{{ t.media_upload }}';
}); });
@@ -106,4 +160,17 @@
})(); })();
</script> </script>
</div> </div>
<style>
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
.upload-queue:empty { display:none; }
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
</style>
{% endblock %} {% endblock %}
+82 -16
View File
@@ -113,13 +113,17 @@
{% 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"> <span class="photo-thumb media-loading-frame is-loading">
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
</span>
</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 media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
<video src="/admin/uploads/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video> {% if !m.thumbnail_url.is_empty() %}
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
{% endif %}
<span class="video-play"></span> <span class="video-play"></span>
</div> </div>
</a> </a>
@@ -163,6 +167,7 @@
<input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required> <input class="input" type="file" id="uploadFiles" name="files" multiple accept="image/*,video/*" required>
</div> </div>
<p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p> <p id="fileCount" style="font-size:0.8rem;color:#888;margin-top:0.3rem;"></p>
<div id="uploadQueue" class="upload-queue"></div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ t.media_caption }}</label> <label class="label">{{ t.media_caption }}</label>
@@ -238,18 +243,16 @@
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.visit-media-item .photo-thumb {
width: 100%;
height: 80px;
}
.visit-media-item .video-thumb-sm { .visit-media-item .video-thumb-sm {
position: relative; position: relative;
width: 100%; width: 100%;
height: 80px; height: 80px;
background: #111; background: #111;
} }
.visit-media-item .video-thumb-sm video {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.visit-media-item .video-play { .visit-media-item .video-play {
position: absolute; position: absolute;
top: 50%; top: 50%;
@@ -297,6 +300,16 @@
max-width: 420px; max-width: 420px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15); box-shadow: 0 4px 24px rgba(0,0,0,0.15);
} }
.upload-queue { display:flex; flex-direction:column; gap:.4rem; margin-top:.65rem; }
.upload-queue:empty { display:none; }
.upload-queue-item { display:grid; grid-template-columns:34px minmax(0,1fr); gap:.55rem; align-items:center; padding:.45rem .55rem; background:#f7f6ff; border-radius:8px; }
.upload-queue-icon { width:34px; height:34px; display:flex; align-items:center; justify-content:center; border-radius:7px; background:#ebe8ff; font-size:1.05rem; }
.upload-queue-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.78rem; color:#494467; }
.upload-queue-state { font-size:.68rem; color:#8a84a5; }
.upload-queue-track { height:3px; overflow:hidden; border-radius:99px; background:#dedbea; margin-top:.25rem; }
.upload-queue-bar { height:100%; width:0; background:#7567e8; transition:width .2s; }
#uploadBar.is-processing, .upload-queue-bar.is-processing { width:35% !important; animation:upload-processing 1.15s ease-in-out infinite; }
@keyframes upload-processing { from { transform:translateX(-110%); } to { transform:translateX(300%); } }
</style> </style>
<script> <script>
@@ -316,6 +329,7 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
var form = document.getElementById('uploadForm'); var form = document.getElementById('uploadForm');
var filesInput = document.getElementById('uploadFiles'); var filesInput = document.getElementById('uploadFiles');
var fileCount = document.getElementById('fileCount'); var fileCount = document.getElementById('fileCount');
var queue = document.getElementById('uploadQueue');
var progress = document.getElementById('uploadProgress'); var progress = document.getElementById('uploadProgress');
var bar = document.getElementById('uploadBar'); var bar = document.getElementById('uploadBar');
var percent = document.getElementById('uploadPercent'); var percent = document.getElementById('uploadPercent');
@@ -336,9 +350,41 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
// Show selected file count // Show selected file count
filesInput.addEventListener('change', function() { filesInput.addEventListener('change', function() {
var n = this.files.length; var n = this.files.length;
fileCount.textContent = n > 0 ? ('Выбрано файлов: ' + n) : ''; fileCount.textContent = n > 0 ? ('{{ t.media_files_selected }}: ' + n) : '';
queue.replaceChildren();
Array.from(this.files).forEach(function(file) {
var item = document.createElement('div');
item.className = 'upload-queue-item';
var icon = document.createElement('span');
icon.className = 'upload-queue-icon';
icon.textContent = file.type.indexOf('video/') === 0 ? '🎬' : '🖼️';
var details = document.createElement('div');
var name = document.createElement('div');
name.className = 'upload-queue-name';
name.textContent = file.name;
var state = document.createElement('div');
state.className = 'upload-queue-state';
state.textContent = '0%';
var track = document.createElement('div');
track.className = 'upload-queue-track';
var itemBar = document.createElement('div');
itemBar.className = 'upload-queue-bar';
track.appendChild(itemBar);
details.append(name, state, track);
item.append(icon, details);
queue.appendChild(item);
});
}); });
function updateQueue(state, progressValue, processing) {
queue.querySelectorAll('.upload-queue-item').forEach(function(item) {
item.querySelector('.upload-queue-state').textContent = state;
var itemBar = item.querySelector('.upload-queue-bar');
itemBar.classList.toggle('is-processing', processing);
if (!processing) itemBar.style.width = progressValue + '%';
});
}
// Submit via XHR for progress tracking // Submit via XHR for progress tracking
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
@@ -350,7 +396,9 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
// Show progress bar, disable submit // Show progress bar, disable submit
progress.style.display = 'block'; progress.style.display = 'block';
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.textContent = 'Загрузка...'; submitBtn.textContent = '{{ t.media_upload_sending }}';
statusText.textContent = '{{ t.media_upload_sending }}';
bar.classList.remove('is-processing');
bar.style.width = '0%'; bar.style.width = '0%';
percent.textContent = '0%'; percent.textContent = '0%';
@@ -359,25 +407,43 @@ document.querySelectorAll('.status-btn').forEach(function(btn) {
var pct = Math.round(ev.loaded / ev.total * 100); var pct = Math.round(ev.loaded / ev.total * 100);
bar.style.width = pct + '%'; bar.style.width = pct + '%';
percent.textContent = pct + '%'; percent.textContent = pct + '%';
if (pct === 100) statusText.textContent = 'Обработка...'; updateQueue(pct + '%', pct, false);
if (pct === 100) {
statusText.textContent = '{{ t.media_upload_processing }}';
percent.textContent = '•••';
bar.classList.add('is-processing');
updateQueue('{{ t.media_upload_processing }}', 100, true);
}
});
xhr.upload.addEventListener('load', function() {
statusText.textContent = '{{ t.media_upload_processing }}';
percent.textContent = '•••';
bar.classList.add('is-processing');
updateQueue('{{ t.media_upload_processing }}', 100, true);
}); });
xhr.addEventListener('load', function() { xhr.addEventListener('load', function() {
if (xhr.status >= 200 && xhr.status < 400) { if (xhr.status >= 200 && xhr.status < 400) {
bar.style.width = '100%'; bar.style.width = '100%';
bar.classList.remove('is-processing');
percent.textContent = '100%'; percent.textContent = '100%';
statusText.textContent = 'Готово!'; statusText.textContent = '{{ t.media_upload_done }}';
updateQueue('{{ t.media_upload_done }}', 100, false);
// Reload page to show uploaded media // Reload page to show uploaded media
setTimeout(function() { window.location.reload(); }, 300); setTimeout(function() { window.location.reload(); }, 300);
} else { } else {
bar.classList.remove('is-processing');
statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')'; statusText.textContent = 'Ошибка загрузки (' + xhr.status + ')';
updateQueue('Ошибка загрузки', 0, false);
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}'; submitBtn.textContent = '{{ t.media_upload }}';
} }
}); });
xhr.addEventListener('error', function() { xhr.addEventListener('error', function() {
statusText.textContent = 'Ошибка соединения'; bar.classList.remove('is-processing');
statusText.textContent = '{{ t.media_upload_connection_error }}';
updateQueue('{{ t.media_upload_connection_error }}', 0, false);
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = '{{ t.media_upload }}'; submitBtn.textContent = '{{ t.media_upload }}';
}); });
+368 -187
View File
@@ -4,228 +4,409 @@
{% block title %}{{ t.settings_title }}{% endblock %} {% block title %}{{ t.settings_title }}{% endblock %}
{% block content %} {% block content %}
<div class="page-head"> <div class="page-head settings-page-head">
<h1>{{ t.settings_title }}</h1> <div>
<h1>{{ t.settings_title }}</h1>
<p>{{ t.settings_intro }}</p>
</div>
</div> </div>
{% if saved %} {% if saved %}
<div class="notification is-success is-light">{{ t.settings_saved }}</div> <div class="notification is-success is-light settings-message">{{ t.settings_saved }}</div>
{% endif %}
{% if let Some(message) = error %}
<div class="notification is-danger is-light settings-message">{{ message }}</div>
{% endif %} {% endif %}
<div class="form-card"> <form id="settingsForm" class="settings-form" method="post" action="/admin/settings/save">
<form method="post" action="/admin/settings/save"> <section class="settings-section">
<header class="settings-section-head">
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2> <span class="settings-section-icon" aria-hidden="true">🌐</span>
<div class="field"> <div>
<label class="label">{{ t.settings_contact_info }}</label> <h2>{{ t.settings_section_general }}</h2>
<div class="control"> <p>{{ t.settings_section_general_help }}</p>
<input class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </header>
<div class="field"> <div class="settings-section-body">
<label class="label">{{ t.settings_pricing_info }}</label> <div class="settings-grid">
<div class="control"> <div class="field settings-field settings-field-wide">
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea> <label class="label" for="contactInfo">{{ t.settings_contact_info }}</label>
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_seo_keywords }}</label>
<div class="control">
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3"
style="resize:vertical;"
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
</div>
<details style="margin-top:1.5rem;">
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
{{ t.settings_section_advanced }}
</summary>
<div style="margin-top:1rem;">
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control"> <div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}"> <input id="contactInfo" class="input" type="text" name="contact_info" placeholder="+7 999 123-45-67 / info@example.com" value="{% for s in &settings %}{% if s.key == "contact_info" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </div>
<div class="field"> <div class="field settings-field">
<label class="label">{{ t.settings_timezone }}</label> <label class="label" for="siteDomain">{{ t.settings_site_domain }}</label>
<div class="control"> <div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}"> <input id="siteDomain" class="input" type="url" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div> </div>
</div> </div>
<div class="field settings-field">
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3> <label class="label" for="timezone">{{ t.settings_timezone }}</label>
<div class="field"> <div class="control">
<label class="checkbox"> <input id="timezone" class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
<input type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}> </div>
{{ t.settings_client_notifications_enabled }}
</label>
<p class="help">{{ t.settings_client_notifications_help }}</p>
</div> </div>
<blockquote class="notification is-warning is-light" style="padding:0.8rem 1rem;font-size:0.85rem;margin:0.75rem 0;"> <div class="field settings-field settings-field-wide">
<label class="label" for="pricingInfo">{{ t.settings_pricing_info }}</label>
<div class="control">
<textarea id="pricingInfo" class="textarea" name="pricing_info" rows="3" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="field settings-field settings-field-wide">
<label class="label" for="seoKeywordsInput">{{ t.settings_seo_keywords }}</label>
<div class="control">
<textarea id="seoKeywordsInput" class="textarea" name="seo_keywords" rows="3" placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
<div id="seoPreview" class="seo-preview" hidden></div>
</div>
</div>
</div>
</section>
<section class="settings-section">
<header class="settings-section-head">
<span class="settings-section-icon" aria-hidden="true">☁️</span>
<div>
<div class="settings-title-row">
<h2>{{ t.settings_section_storage }}</h2>
<span class="settings-status{% if r2_enabled_checked %} is-enabled{% endif %}">
{% if r2_enabled_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
</span>
</div>
<p>{{ t.settings_r2_help }}</p>
</div>
</header>
<div class="settings-section-body">
<label class="settings-toggle" for="r2Enabled">
<input id="r2Enabled" type="checkbox" name="r2_enabled" value="true"{% if r2_enabled_checked %} checked{% endif %}>
<span class="toggle-track" aria-hidden="true"><span></span></span>
<span>
<strong>{{ t.settings_r2_enabled }}</strong>
<small>{{ t.settings_r2_help }}</small>
</span>
</label>
<div class="settings-note settings-note-info">{{ t.settings_r2_migration_help }}</div>
<div class="settings-grid">
<div class="field settings-field">
<label class="label" for="r2AccountId">{{ t.settings_r2_account_id }}</label>
<div class="control">
<input id="r2AccountId" class="input is-family-monospace" type="text" name="r2_account_id" maxlength="32" autocomplete="off" spellcheck="false" placeholder="0123456789abcdef0123456789abcdef" value="{% for s in &settings %}{% if s.key == "r2_account_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="r2Bucket">{{ t.settings_r2_bucket }}</label>
<div class="control">
<input id="r2Bucket" class="input is-family-monospace" type="text" name="r2_bucket" autocomplete="off" spellcheck="false" placeholder="pet-media" value="{% for s in &settings %}{% if s.key == "r2_bucket" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="r2AccessKey">{{ t.settings_r2_access_key_id }}</label>
<div class="control">
<input id="r2AccessKey" class="input is-family-monospace" type="text" name="r2_access_key_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "r2_access_key_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="r2Secret">{{ t.settings_r2_secret_access_key }}</label>
<div class="control secret-control">
<input id="r2Secret" class="input is-family-monospace" type="password" name="r2_secret_access_key" autocomplete="new-password" placeholder="••••••••••••••••">
</div>
<p class="settings-help">{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
{% if r2_secret_configured %}
<label class="secret-clear"><input type="checkbox" name="clear_r2_secret_access_key" value="true"> {{ t.settings_secret_clear }}</label>
{% endif %}
</div>
</div>
</div>
</section>
<section class="settings-section">
<header class="settings-section-head">
<span class="settings-section-icon" aria-hidden="true">🔔</span>
<div>
<div class="settings-title-row">
<h2>{{ t.settings_section_notifications }}</h2>
<span class="settings-status{% if client_notifications_checked %} is-enabled{% endif %}">
{% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %}
</span>
</div>
<p>{{ t.settings_section_notifications_help }}</p>
</div>
</header>
<div class="settings-section-body">
<label class="settings-toggle" for="clientNotifications">
<input id="clientNotifications" type="checkbox" name="client_notifications_enabled" value="true"{% if client_notifications_checked %} checked{% endif %}>
<span class="toggle-track" aria-hidden="true"><span></span></span>
<span>
<strong>{{ t.settings_client_notifications_enabled }}</strong>
<small>{{ t.settings_client_notifications_help }}</small>
</span>
</label>
<div class="settings-subsection">
<h3>Web Push / VAPID</h3>
<div class="settings-note settings-note-warning">
<p>{{ t.settings_vapid_warning }}</p> <p>{{ t.settings_vapid_warning }}</p>
<p style="margin-top:0.45rem;">{{ t.settings_vapid_generate }}</p> <p>{{ t.settings_vapid_generate }} <code>cargo run --bin generate_vapid</code></p>
<code style="display:inline-block;margin-top:0.2rem;user-select:all;">cargo run --bin generate_vapid</code> </div>
</blockquote> <div class="settings-grid">
<div class="field"> <div class="field settings-field settings-field-wide">
<label class="label">{{ t.settings_vapid_public_key }}</label> <label class="label" for="vapidPublic">{{ t.settings_vapid_public_key }}</label>
<div class="control"> <div class="control">
<input class="input" type="text" name="vapid_public_key" autocomplete="off" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}"> <input id="vapidPublic" class="input is-family-monospace" type="text" name="vapid_public_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "vapid_public_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field settings-field-wide">
<label class="label" for="vapidPrivate">{{ t.settings_vapid_private_key }}</label>
<div class="control secret-control">
<input id="vapidPrivate" class="input is-family-monospace" type="password" name="vapid_private_key" autocomplete="new-password" placeholder="••••••••••••••••">
</div>
<p class="settings-help">{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
{% if vapid_private_key_configured %}
<label class="secret-clear"><input type="checkbox" name="clear_vapid_private_key" value="true"> {{ t.settings_secret_clear }}</label>
{% endif %}
</div>
<div class="field settings-field settings-field-wide">
<label class="label" for="vapidSubject">{{ t.settings_vapid_subject }}</label>
<div class="control">
<input id="vapidSubject" class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div> </div>
</div> </div>
<div class="field">
<label class="label">{{ t.settings_vapid_private_key }}</label> <details class="settings-details">
<div class="control"> <summary>{{ t.settings_push_subscribers }} <span>{{ push_subscribers.len() }}</span></summary>
<input class="input" type="password" name="vapid_private_key" autocomplete="new-password" value="{% for s in &settings %}{% if s.key == "vapid_private_key" %}{{ s.value }}{% endif %}{% endfor %}"> <div class="settings-details-body">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_vapid_subject }}</label>
<div class="control">
<input class="input" type="text" name="vapid_subject" placeholder="mailto:admin@example.com" value="{% for s in &settings %}{% if s.key == "vapid_subject" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<details style="margin:0.9rem 0 1.25rem;border:1px solid #e8e5f5;border-radius:8px;background:#faf9ff;">
<summary style="cursor:pointer;padding:0.7rem 0.85rem;font-weight:600;font-size:0.9rem;">
{{ t.settings_push_subscribers }} ({{ push_subscribers.len() }})
</summary>
<div style="padding:0 0.85rem 0.85rem;overflow-x:auto;">
{% if push_subscribers.is_empty() %} {% if push_subscribers.is_empty() %}
<p class="help">{{ t.settings_push_no_subscribers }}</p> <p class="settings-help">{{ t.settings_push_no_subscribers }}</p>
{% else %} {% else %}
<table class="table is-fullwidth is-striped is-narrow" style="font-size:0.8rem;background:transparent;"> <div class="settings-table-wrap">
<thead><tr> <table class="table is-fullwidth is-striped is-narrow">
<th>{{ t.settings_push_client }}</th> <thead><tr>
<th>{{ t.settings_push_devices }}</th> <th>{{ t.settings_push_client }}</th>
<th>{{ t.settings_push_language }}</th> <th>{{ t.settings_push_devices }}</th>
<th>{{ t.settings_push_updated }}</th> <th>{{ t.settings_push_language }}</th>
</tr></thead> <th>{{ t.settings_push_updated }}</th>
<tbody> </tr></thead>
{% for subscriber in &push_subscribers %} <tbody>
<tr> {% for subscriber in &push_subscribers %}
<td>{{ subscriber.client_name }}</td> <tr>
<td>{{ subscriber.device_count }}</td> <td>{{ subscriber.client_name }}</td>
<td>{{ subscriber.languages }}</td> <td>{{ subscriber.device_count }}</td>
<td style="white-space:nowrap;">{{ subscriber.last_updated }}</td> <td>{{ subscriber.languages }}</td>
</tr> <td class="is-nowrap">{{ subscriber.last_updated }}</td>
{% endfor %} </tr>
</tbody> {% endfor %}
</table> </tbody>
</table>
</div>
{% endif %} {% endif %}
</div> </div>
</details> </details>
<div class="field"> </div>
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3> <div class="settings-subsection">
<div class="field"> <h3>Telegram</h3>
<label class="label">{{ t.settings_turnstile_site_key }}</label> <div class="field settings-field settings-field-wide">
<div class="control"> <label class="label" for="telegramToken">{{ t.settings_telegram_bot_token }}</label>
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}"> <div class="control secret-control">
<input id="telegramToken" class="input is-family-monospace" type="password" name="telegram_bot_token" autocomplete="new-password" placeholder="••••••••••••••••">
</div> </div>
</div> <p class="settings-help">{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
<div class="field"> {% if telegram_bot_token_configured %}
<label class="label">{{ t.settings_turnstile_secret_key }}</label> <label class="secret-clear"><input type="checkbox" name="clear_telegram_bot_token" value="true"> {{ t.settings_secret_clear }}</label>
<div class="control"> {% endif %}
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
<div class="field">
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_secret }}</label>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
{{ t.settings_auth_password_enabled }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div> </div>
</div> </div>
</details> </div>
</section>
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button> <section class="settings-section">
</form> <header class="settings-section-head">
</div> <span class="settings-section-icon" aria-hidden="true">🛡️</span>
<div>
<h2>{{ t.settings_section_captcha }}</h2>
<p>{{ t.settings_section_captcha_help }}</p>
</div>
</header>
<div class="settings-section-body">
<div class="settings-grid">
<div class="field settings-field">
<label class="label" for="turnstileSiteKey">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input id="turnstileSiteKey" class="input is-family-monospace" type="text" name="turnstile_site_key" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="turnstileSecret">{{ t.settings_turnstile_secret_key }}</label>
<div class="control secret-control">
<input id="turnstileSecret" class="input is-family-monospace" type="password" name="turnstile_secret_key" autocomplete="new-password" placeholder="••••••••••••••••">
</div>
<p class="settings-help">{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
{% if turnstile_secret_configured %}
<label class="secret-clear"><input type="checkbox" name="clear_turnstile_secret_key" value="true"> {{ t.settings_secret_clear }}</label>
{% endif %}
</div>
</div>
</div>
</section>
<section class="settings-section">
<header class="settings-section-head">
<span class="settings-section-icon" aria-hidden="true">🔐</span>
<div>
<h2>{{ t.settings_section_oidc }}</h2>
<p>{{ t.settings_section_oidc_help }}</p>
</div>
</header>
<div class="settings-section-body">
<div class="settings-toggle-list">
<label class="settings-toggle" for="passwordAuth">
<input id="passwordAuth" type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
<span class="toggle-track" aria-hidden="true"><span></span></span>
<span><strong>{{ t.settings_auth_password_enabled }}</strong></span>
</label>
<label class="settings-toggle" for="ssoAuth">
<input id="ssoAuth" type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
<span class="toggle-track" aria-hidden="true"><span></span></span>
<span><strong>{{ t.settings_auth_sso_enabled }}</strong></span>
</label>
</div>
<div class="settings-grid">
<div class="field settings-field settings-field-wide">
<label class="label" for="oidcIssuer">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input id="oidcIssuer" class="input" type="url" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="oidcClientId">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input id="oidcClientId" class="input is-family-monospace" type="text" name="oidc_client_id" autocomplete="off" spellcheck="false" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field settings-field">
<label class="label" for="oidcClientSecret">{{ t.settings_oidc_client_secret }}</label>
<div class="control secret-control">
<input id="oidcClientSecret" class="input is-family-monospace" type="password" name="oidc_client_secret" autocomplete="new-password" placeholder="••••••••••••••••">
</div>
<p class="settings-help">{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}</p>
{% if oidc_client_secret_configured %}
<label class="secret-clear"><input type="checkbox" name="clear_oidc_client_secret" value="true"> {{ t.settings_secret_clear }}</label>
{% endif %}
</div>
<div class="field settings-field settings-field-wide">
<label class="label" for="oidcGroups">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input id="oidcGroups" class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
</div>
</div>
</section>
<div class="settings-save-bar">
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
</div>
</form>
<style>
.settings-page-head { align-items:flex-end; margin-bottom:1.25rem; }
.settings-page-head h1 { font-size:1.55rem; }
.settings-page-head p { margin-top:.25rem; color:#777; font-size:.9rem; }
.settings-message { border-radius:10px; margin-bottom:1rem; }
.settings-form { display:flex; flex-direction:column; gap:1rem; }
.settings-section { background:#fff; border:1px solid #e7e5ef; border-radius:14px; overflow:hidden; box-shadow:0 2px 10px rgba(42,34,80,.035); }
.settings-section-head { display:grid; grid-template-columns:42px minmax(0,1fr); gap:.8rem; padding:1rem 1.15rem; background:linear-gradient(180deg,#fff,#fbfaff); border-bottom:1px solid #eceaf2; }
.settings-section-icon { display:flex; align-items:center; justify-content:center; width:42px; height:42px; border-radius:11px; background:#efedff; font-size:1.2rem; }
.settings-section-head h2 { margin:0; font-size:1.05rem; font-weight:750; color:#302c49; }
.settings-section-head p { margin:.2rem 0 0; max-width:680px; color:#817c94; font-size:.82rem; line-height:1.45; }
.settings-title-row { display:flex; align-items:center; flex-wrap:wrap; gap:.55rem; }
.settings-status { display:inline-flex; align-items:center; padding:.12rem .48rem; border-radius:99px; background:#eeedf2; color:#777184; font-size:.67rem; font-weight:700; }
.settings-status.is-enabled { background:#dcf7e8; color:#177349; }
.settings-section-body { padding:1.15rem; }
.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem 1.1rem; }
.settings-field { min-width:0; margin:0 !important; }
.settings-field-wide { grid-column:1 / -1; }
.settings-field .label { margin-bottom:.38rem; font-size:.78rem; font-weight:700; color:#514c66 !important; }
.settings-field .input, .settings-field .textarea { border-radius:8px; box-shadow:none; font-size:.88rem; }
.settings-field .input:focus, .settings-field .textarea:focus { border-color:#7c6cff !important; box-shadow:0 0 0 2px rgba(124,108,255,.12); }
.settings-field .textarea { min-height:76px; resize:vertical; }
.settings-help { margin-top:.35rem; color:#8b869b; font-size:.72rem; line-height:1.4; }
.secret-control { position:relative; }
.secret-control::after { content:"🔒"; position:absolute; right:.75rem; top:50%; transform:translateY(-50%); font-size:.78rem; opacity:.48; pointer-events:none; }
.secret-control .input { padding-right:2.25rem; letter-spacing:.05em; }
.secret-clear { display:inline-flex; align-items:center; gap:.35rem; margin-top:.38rem; color:#a04e58 !important; font-size:.7rem; cursor:pointer; }
.secret-clear input { accent-color:#b85c66; }
.settings-toggle-list { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.7rem; margin-bottom:1rem; }
.settings-toggle { display:flex; align-items:flex-start; gap:.7rem; padding:.75rem .85rem; border:1px solid #e8e5f2; border-radius:10px; background:#fbfaff; cursor:pointer; }
.settings-toggle > input { position:absolute; opacity:0; pointer-events:none; }
.toggle-track { flex:0 0 auto; width:38px; height:22px; padding:2px; border-radius:99px; background:#ccc8d6; transition:.18s; }
.toggle-track span { display:block; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 4px rgba(0,0,0,.2); transition:.18s; }
.settings-toggle > input:checked + .toggle-track { background:#7567e8; }
.settings-toggle > input:checked + .toggle-track span { transform:translateX(16px); }
.settings-toggle > input:focus-visible + .toggle-track { outline:2px solid #7c6cff; outline-offset:2px; }
.settings-toggle strong { display:block; color:#3e3956; font-size:.82rem; line-height:1.35; }
.settings-toggle small { display:block; margin-top:.15rem; color:#8a849c; font-size:.7rem; line-height:1.35; }
.settings-note { margin:.9rem 0 1rem; padding:.75rem .9rem; border:1px solid; border-radius:9px; font-size:.76rem; line-height:1.5; }
.settings-note p + p { margin-top:.35rem; }
.settings-note-info { background:#f1f7ff; border-color:#d7e8fa; color:#42627f; }
.settings-note-warning { background:#fff8e8; border-color:#f1dfb4; color:#785f28; }
.settings-note code { display:inline-block; margin:.2rem 0 0; padding:.12rem .35rem; border-radius:4px; background:rgba(255,255,255,.7); color:inherit; user-select:all; }
.settings-subsection { margin-top:1.1rem; padding-top:1.1rem; border-top:1px solid #eeecf3; }
.settings-subsection h3 { margin:0 0 .7rem; color:#514b69; font-size:.82rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; }
.settings-details { margin-top:1rem; border:1px solid #e8e5f2; border-radius:9px; background:#fcfbff; overflow:hidden; }
.settings-details summary { display:flex; justify-content:space-between; align-items:center; padding:.7rem .85rem; cursor:pointer; color:#514b69; font-size:.78rem; font-weight:700; }
.settings-details summary span { min-width:24px; padding:.05rem .4rem; border-radius:99px; background:#ece9ff; color:#6257bb; text-align:center; }
.settings-details-body { padding:0 .85rem .85rem; }
.settings-table-wrap { overflow-x:auto; }
.settings-table-wrap .table { margin:0; background:transparent; font-size:.75rem; }
.is-nowrap { white-space:nowrap; }
.seo-preview { margin-top:.5rem; padding:.5rem .65rem; border:1px solid #ebe9ef; border-radius:7px; background:#fafafa; min-height:2rem; line-height:2; font-size:.78rem; }
.settings-save-bar { position:sticky; bottom:.75rem; z-index:10; display:flex; justify-content:flex-end; padding:.65rem; border:1px solid rgba(222,218,238,.9); border-radius:12px; background:rgba(255,255,255,.92); box-shadow:0 8px 28px rgba(46,38,86,.12); backdrop-filter:blur(8px); }
.settings-save-bar .button { min-width:150px; border-radius:8px; font-weight:700; }
@media (max-width:700px) {
.settings-grid, .settings-toggle-list { grid-template-columns:1fr; }
.settings-field-wide { grid-column:auto; }
.settings-section-head { grid-template-columns:36px minmax(0,1fr); padding:.9rem; }
.settings-section-icon { width:36px; height:36px; }
.settings-section-body { padding:.9rem; }
.settings-save-bar { bottom:4.15rem; }
.settings-save-bar .button { width:100%; }
}
</style>
<script> <script>
(function() { (function() {
var COLORS = [ var colors = [
'rgba(124,108,255,0.18)', 'rgba(124,108,255,.18)', 'rgba(255,82,135,.15)',
'rgba(255,82,135,0.15)', 'rgba(255,140,38,.18)', 'rgba(0,180,150,.15)',
'rgba(255,140,38,0.18)', 'rgba(77,166,255,.18)', 'rgba(255,179,64,.18)',
'rgba(0,180,150,0.15)', 'rgba(176,108,255,.16)', 'rgba(34,180,130,.16)'
'rgba(77,166,255,0.18)',
'rgba(255,179,64,0.18)',
'rgba(176,108,255,0.16)',
'rgba(34,180,130,0.16)',
]; ];
var input = document.getElementById('seoKeywordsInput');
var ta = document.getElementById('seoKeywordsInput');
var preview = document.getElementById('seoPreview'); var preview = document.getElementById('seoPreview');
function esc(s) { function renderSeoPreview() {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); var words = input.value.split(',').map(function(value) { return value.trim(); }).filter(Boolean);
} preview.replaceChildren();
preview.hidden = words.length === 0;
function render() { words.forEach(function(word, index) {
var text = ta.value.trim(); var tag = document.createElement('span');
if (!text) { preview.style.display = 'none'; return; } tag.textContent = word;
tag.style.background = colors[index % colors.length];
var parts = text.split(','); tag.style.borderRadius = '4px';
var html = ''; tag.style.padding = '2px 6px';
parts.forEach(function(part, i) { tag.style.margin = '2px';
var word = part.trim(); preview.appendChild(tag);
if (word) {
var color = COLORS[i % COLORS.length];
html += '<span style="background:' + color + ';border-radius:4px;padding:2px 6px;margin:2px;">' + esc(word) + '</span>';
}
if (i < parts.length - 1) {
html += '<span style="color:#ccc;font-size:0.8em;margin:0 1px">,</span>';
}
}); });
preview.innerHTML = html;
preview.style.display = 'block';
} }
ta.addEventListener('input', render); input.addEventListener('input', renderSeoPreview);
render(); renderSeoPreview();
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+3 -3
View File
@@ -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>
+12 -5
View File
@@ -57,6 +57,9 @@
.media-row img { .media-row img {
width: 80px; height: 60px; object-fit: cover; border-radius: 6px; width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
} }
.media-row .media-thumb-frame {
width: 80px; height: 60px; border-radius: 6px;
}
.media-row .vid-thumb { .media-row .vid-thumb {
position: relative; width: 80px; height: 60px; border-radius: 6px; position: relative; width: 80px; height: 60px; border-radius: 6px;
overflow: hidden; background: #111; overflow: hidden; background: #111;
@@ -191,13 +194,17 @@
<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"> <span class="media-thumb-frame media-loading-frame is-loading">
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
</span>
</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 media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video> {% if !m.thumbnail_url.is_empty() %}
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
{% endif %}
<span class="video-play"></span> <span class="video-play"></span>
</div> </div>
</a> </a>
+2 -2
View File
@@ -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>
+131 -16
View File
@@ -1,49 +1,164 @@
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)"> <div class="lightbox-overlay" id="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox(event)">&times;</button> <button class="lightbox-close" type="button" onclick="closeLightbox(event)">&times;</button>
<img id="lightboxImg" src="" alt=""> <div class="lightbox-stage" id="lightboxStage">
<video id="lightboxVideo" controls style="display:none;"></video> <span class="lightbox-loader" aria-hidden="true"></span>
<img id="lightboxImg" src="" alt="">
<video id="lightboxVideo" controls playsinline preload="auto" style="display:none;"></video>
</div>
</div> </div>
<style> <style>
.media-loading-frame {
position: relative; display: block; overflow: hidden; background: #eceaf5;
}
.media-loading-frame::after, .lightbox-loader {
content: ""; position: absolute; z-index: 3; top: 50%; left: 50%;
width: 22px; height: 22px; margin: -11px 0 0 -11px;
border: 3px solid rgba(124,108,255,.22); border-top-color: #7c6cff;
border-radius: 50%; animation: media-spinner .75s linear infinite;
}
.media-loading-frame:not(.is-loading)::after { display: none; }
.media-loading-frame img[data-media-load] { opacity: 0; transition: opacity .18s ease; }
.media-loading-frame.is-loaded img[data-media-load] { opacity: 1; }
.media-loading-frame.is-error::after {
display: block; content: "!"; width: 24px; height: 24px; margin: -12px 0 0 -12px;
border: 0; animation: none; color: #9b93bb; font-weight: 700; text-align: center;
}
.video-preview-sprite {
display: block !important; width: 400% !important; max-width: none !important;
height: 100% !important; object-fit: fill !important;
transform: translateX(0); transition: transform .08s linear;
}
@keyframes media-spinner { to { transform: rotate(360deg); } }
.lightbox-overlay { .lightbox-overlay {
display:none; position:fixed; inset:0; z-index:200; display:none; position:fixed; inset:0; z-index:200;
background:rgba(0,0,0,0.85); align-items:center; justify-content:center; background:rgba(9,8,18,.9); align-items:center; justify-content:center;
padding: 1rem;
} }
.lightbox-overlay.is-open { display:flex; } .lightbox-overlay.is-open { display:flex; }
.lightbox-overlay img, .lightbox-overlay video { .lightbox-stage {
max-width:92vw; max-height:88vh; border-radius:8px; object-fit:contain; position: relative; display: flex; align-items: center; justify-content: center;
min-width: 96px; min-height: 96px; max-width: 94vw; max-height: 90vh;
} }
.lightbox-stage img, .lightbox-stage video {
max-width:92vw; max-height:88vh; border-radius:10px; object-fit:contain;
background:#090909; box-shadow:0 18px 60px rgba(0,0,0,.42);
}
.lightbox-stage.is-loading img, .lightbox-stage.is-loading video { opacity: .18; }
.lightbox-stage:not(.is-loading) .lightbox-loader { display: none; }
.lightbox-stage.is-error .lightbox-loader {
display: block; animation: none; border: 0; color: white;
}
.lightbox-stage.is-error .lightbox-loader::after { content: "!"; font-size: 2rem; }
.lightbox-close { .lightbox-close {
position:absolute; top:0.75rem; right:1rem; background:none; border:none; position:absolute; top:0.75rem; right:1rem; background:rgba(0,0,0,.25); border:none;
color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201; color:#fff; font-size:2.2rem; cursor:pointer; line-height:1; z-index:201;
width:44px; height:44px; border-radius:50%;
} }
</style> </style>
<script> <script>
(function() {
function finishThumbnail(image, loaded) {
var frame = image.closest('.media-loading-frame');
if (!frame) return;
frame.classList.remove('is-loading');
frame.classList.add(loaded ? 'is-loaded' : 'is-error');
}
document.querySelectorAll('img[data-media-load]').forEach(function(image) {
image.addEventListener('load', function() { finishThumbnail(image, true); });
image.addEventListener('error', function() { finishThumbnail(image, false); });
if (image.complete) finishThumbnail(image, image.naturalWidth > 0);
});
document.querySelectorAll('[data-video-preview]').forEach(function(preview) {
var sprite = preview.querySelector('[data-preview-frames]');
if (!sprite) return;
var timer;
var frame = 0;
var frames = Number(sprite.dataset.previewFrames) || 4;
function showFrame() {
sprite.style.transform = 'translateX(-' + (frame * 100 / frames) + '%)';
}
function start() {
if (timer || !preview.classList.contains('is-loaded')) return;
frame = 1;
showFrame();
timer = window.setInterval(function() {
frame = (frame + 1) % frames;
showFrame();
}, 650);
}
function stop() {
window.clearInterval(timer);
timer = null;
frame = 0;
showFrame();
}
preview.addEventListener('pointerenter', start);
preview.addEventListener('pointerleave', stop);
preview.closest('a').addEventListener('focus', start);
preview.closest('a').addEventListener('blur', stop);
});
})();
function setLightboxState(state) {
var stage = document.getElementById('lightboxStage');
stage.classList.remove('is-loading', 'is-error');
if (state) stage.classList.add(state);
}
function openLightbox(url, isVideo) { function openLightbox(url, isVideo) {
var lb = document.getElementById('lightbox'); var lb = document.getElementById('lightbox');
var img = document.getElementById('lightboxImg'); var img = document.getElementById('lightboxImg');
var vid = document.getElementById('lightboxVideo'); var vid = document.getElementById('lightboxVideo');
setLightboxState('is-loading');
lb.classList.add('is-open');
if (isVideo) { if (isVideo) {
img.style.display = 'none'; img.style.display = 'none';
img.removeAttribute('src');
vid.style.display = ''; vid.style.display = '';
vid.src = url; vid.src = url;
vid.load();
var playback = vid.play();
if (playback) playback.catch(function() {});
} else { } else {
vid.style.display = 'none'; vid.style.display = 'none';
vid.pause && vid.pause(); vid.src = ''; vid.pause();
vid.removeAttribute('src');
vid.load();
img.style.display = ''; img.style.display = '';
img.src = url; img.src = url;
} }
lb.classList.add('is-open');
} }
function closeLightbox(e) { function closeLightbox(event) {
if (e && e.target !== document.getElementById('lightbox') && e.target.className !== 'lightbox-close') return;
var lb = document.getElementById('lightbox'); var lb = document.getElementById('lightbox');
if (event && event.target !== lb && !event.target.closest('.lightbox-close')) return;
lb.classList.remove('is-open'); lb.classList.remove('is-open');
var vid = document.getElementById('lightboxVideo'); var vid = document.getElementById('lightboxVideo');
vid.pause && vid.pause(); vid.src = ''; vid.pause();
vid.removeAttribute('src');
vid.load();
var img = document.getElementById('lightboxImg');
img.removeAttribute('src');
setLightboxState(null);
} }
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(null); }); document.getElementById('lightboxImg').addEventListener('load', function() { setLightboxState(null); });
document.addEventListener('click', function(e) { document.getElementById('lightboxImg').addEventListener('error', function() { setLightboxState('is-error'); });
var a = e.target.closest('[data-lightbox]'); var lightboxVideo = document.getElementById('lightboxVideo');
if (a) { e.preventDefault(); openLightbox(a.href, a.dataset.lightbox === 'video'); } ['loadeddata', 'canplay', 'playing'].forEach(function(name) {
lightboxVideo.addEventListener(name, function() { setLightboxState(null); });
});
['waiting', 'stalled', 'seeking'].forEach(function(name) {
lightboxVideo.addEventListener(name, function() { setLightboxState('is-loading'); });
});
lightboxVideo.addEventListener('error', function() { setLightboxState('is-error'); });
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') closeLightbox(null);
});
document.addEventListener('click', function(event) {
var link = event.target.closest('[data-lightbox]');
if (!link) return;
event.preventDefault();
openLightbox(link.href, link.dataset.lightbox === 'video');
}); });
</script> </script>