diff --git a/src/admin.rs b/src/admin.rs index 22279f7..1e969bf 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -346,10 +346,31 @@ struct SettingsTemplate<'a> { auth_sso_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, } +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)] struct PushSubscriberItem { client_name: String, @@ -1312,11 +1333,12 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot: .find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY) .map(|setting| setting.value == "true") .unwrap_or(false); - let r2_secret_configured = settings - .iter() - .find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY) - .map(|setting| !setting.value.trim().is_empty()) - .unwrap_or(false); + let 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 { t: lang.t(), lang, @@ -1328,6 +1350,10 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot: auth_sso_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?, } @@ -1417,6 +1443,16 @@ struct SettingsForm { r2_access_key_id: String, r2_secret_access_key: String, #[serde(default)] + clear_telegram_bot_token: Option, + #[serde(default)] + clear_turnstile_secret_key: Option, + #[serde(default)] + clear_oidc_client_secret: Option, + #[serde(default)] + clear_vapid_private_key: Option, + #[serde(default)] + clear_r2_secret_access_key: Option, + #[serde(default)] auth_password_enabled: Option, #[serde(default)] auth_sso_enabled: Option, @@ -1441,16 +1477,36 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: .map(|setting| setting.value.as_str()) .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() || !old_value("vapid_private_key").trim().is_empty(); let vapid_keys_changed = had_vapid_keys && (old_value("vapid_public_key").trim() != form.vapid_public_key.trim() - || old_value("vapid_private_key").trim() != form.vapid_private_key.trim()); - let r2_secret_access_key = if form.r2_secret_access_key.trim().is_empty() { - old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY).to_string() - } else { - form.r2_secret_access_key.trim().to_string() - }; + || 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, @@ -1463,20 +1519,20 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: (requested_r2_enabled && !r2_config_valid).then_some(lang.t().settings_r2_error_incomplete); for (key, value) in [ - ("telegram_bot_token", form.telegram_bot_token), + ("telegram_bot_token", telegram_bot_token), ("contact_info", form.contact_info), ("pricing_info", form.pricing_info), ("timezone", form.timezone), ("site_domain", form.site_domain), ("seo_keywords", form.seo_keywords), ("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_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), ("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), ( crate::uploads::R2_ACCOUNT_ID_KEY, @@ -1574,11 +1630,12 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: .find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY) .map(|setting| setting.value == "true") .unwrap_or(false); - let r2_secret_configured = settings - .iter() - .find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY) - .map(|setting| !setting.value.trim().is_empty()) - .unwrap_or(false); + let 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 { t: lang.t(), lang, @@ -1590,6 +1647,10 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: auth_sso_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?, } @@ -3192,3 +3253,16 @@ pub fn admin_router() -> Router { 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"); + } +} diff --git a/src/bin/normalize_videos_to_r2.rs b/src/bin/normalize_videos_to_r2.rs index f1dcf0e..5b51ad5 100644 --- a/src/bin/normalize_videos_to_r2.rs +++ b/src/bin/normalize_videos_to_r2.rs @@ -45,10 +45,33 @@ async fn run() -> Result<(), Box> { let source_exists = local_file_exists(&source_path).await?; let target_exists = local_file_exists(&target_path).await?; let target_thumbnail_exists = local_file_exists(&target_thumbnail_path).await?; + let mut temporary_source_dir = None; - if !source_exists && !target_exists { + let conversion_source = if target_exists { + target_path.clone() + } else if source_exists { + source_path.clone() + } else if r2.exists(&original_db_path).await? { + let directory = std::env::temp_dir() + .join(format!("web-petting-normalize-{}", uuid::Uuid::new_v4())); + tokio::fs::create_dir(&directory).await?; + let extension = original_db_path.rsplit('.').next().unwrap_or("mov"); + let temporary_source = directory.join(format!("source.{extension}")); + println!("Downloading missing PVC source from R2 for media {media_id}"); + if let Err(error) = r2 + .download_to_path(&original_db_path, &temporary_source) + .await + { + eprintln!("Could not download media {media_id} from R2: {error}"); + let _ = tokio::fs::remove_dir_all(&directory).await; + failed += 1; + continue; + } + temporary_source_dir = Some(directory); + temporary_source + } else { eprintln!( - "Missing PVC source for media {media_id}: {}", + "Missing source for media {media_id}: {}", source_path.display() ); if media.status == "active" { @@ -57,34 +80,34 @@ async fn run() -> Result<(), Box> { missing_archived += 1; } continue; - } + }; - if original_db_path == target_db_path || target_exists { - if !target_thumbnail_exists - && let Err(error) = - uploads::ensure_video_thumbnail(&uploads::Storage::Local, &target_db_path) - .await - { - eprintln!("Could not create preview for media {media_id}: {error}"); - failed += 1; - continue; + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let preparation = if original_db_path == target_db_path || target_exists { + if target_thumbnail_exists { + Ok(()) + } else { + uploads::create_video_thumbnail_file(&conversion_source, &target_thumbnail_path) + .await } } else { - if let Some(parent) = target_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } println!("Converting media {media_id}: {original_db_path}"); - if let Err(error) = uploads::create_compact_video_files( - &source_path, + uploads::create_compact_video_files( + &conversion_source, &target_path, &target_thumbnail_path, ) .await - { - eprintln!("Could not convert media {media_id}: {error}"); - failed += 1; - continue; - } + }; + if let Some(directory) = temporary_source_dir { + let _ = tokio::fs::remove_dir_all(directory).await; + } + if let Err(error) = preparation { + eprintln!("Could not prepare media {media_id}: {error}"); + failed += 1; + continue; } if !target_in_r2 { diff --git a/src/i18n.rs b/src/i18n.rs index 8572f4a..01589f8 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -131,6 +131,16 @@ pub struct Translations { pub settings_save: &'static str, pub settings_saved: &'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_chat_id: &'static str, pub settings_contact_info: &'static str, @@ -406,6 +416,16 @@ static RU: Translations = Translations { settings_save: "Сохранить", settings_saved: "Сохранено!", 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_chat_id: "Chat ID для уведомлений", settings_contact_info: "Контактная информация (отображается на лендинге)", @@ -671,6 +691,16 @@ static EN: Translations = Translations { settings_save: "Save", settings_saved: "Saved!", 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_chat_id: "Notification Chat ID", settings_contact_info: "Contact info (shown on landing page)", diff --git a/src/uploads.rs b/src/uploads.rs index 5de6dfb..b7e19ad 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -175,7 +175,7 @@ impl R2Storage { Ok(bytes.into_bytes().to_vec()) } - async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> { + pub async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> { let response = self .client .get_object() @@ -347,7 +347,7 @@ impl Storage { } } - async fn download_to_path(&self, db_path: &str, destination: &Path) -> StorageResult<()> { + 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) @@ -574,34 +574,68 @@ async fn run_media_command(command: &mut Command, context: &str) -> StorageResul )) } -async fn transcode_video_for_browser(source: &Path, destination: &Path) -> StorageResult<()> { +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", "-i"]) + .args([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-ignore_unknown", + "-i", + ]) .arg(source) - .args(["-map", "0:v:0", "-map", "0:a?"]) + .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(1920,iw)':h='min(1920,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2", + "scale=w='min(1280,iw)':h='min(1280,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2", "-c:v", "libx264", "-preset", - "veryfast", + "fast", "-crf", - "23", + "26", "-pix_fmt", "yuv420p", + "-tag:v", + "avc1", "-c:a", "aac", "-b:a", - "128k", + "96k", "-movflags", "+faststart", "-threads", "2", ]) .arg(destination); - run_media_command(&mut command, "failed to transcode video with ffmpeg").await + 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 { @@ -665,6 +699,10 @@ async fn create_video_sprite(source: &Path, destination: &Path) -> StorageResult 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( @@ -723,7 +761,7 @@ pub async fn ensure_video_thumbnail(storage: &Storage, db_path: &str) -> Storage let thumbnail = workspace.join("preview.jpg"); let result = async { storage.download_to_path(db_path, &source).await?; - create_video_sprite(&source, &thumbnail).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))?; diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 1580529..b6868f2 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -4,267 +4,409 @@ {% block title %}{{ t.settings_title }}{% endblock %} {% block content %} -
-

{{ t.settings_title }}

+
+
+

{{ t.settings_title }}

+

{{ t.settings_intro }}

+
{% if saved %} -
{{ t.settings_saved }}
+
{{ t.settings_saved }}
{% endif %} {% if let Some(message) = error %} -
{{ message }}
+
{{ message }}
{% endif %} -
-
- -

{{ t.settings_contact_info }}

-
- -
- + +
+
+ +
+

{{ t.settings_section_general }}

+

{{ t.settings_section_general_help }}

+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ +
-
- -
- +
+ +
+
+ +
+
+

{{ t.settings_section_storage }}

+ + {% if r2_enabled_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %} + +
+

{{ t.settings_r2_help }}

+
+
+
+ + +
{{ t.settings_r2_migration_help }}
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+

{% if r2_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}

+ {% if r2_secret_configured %} + + {% endif %} +
-
- -
- +
+ +
+
+ +
+
+

{{ t.settings_section_notifications }}

+ + {% if client_notifications_checked %}{{ t.settings_status_enabled }}{% else %}{{ t.settings_status_disabled }}{% endif %} + +
+

{{ t.settings_section_notifications_help }}

- -
+ +
+ -
- - {{ t.settings_section_advanced }} - - -
-

{{ t.settings_section_general }}

-
- -
- -
-
-
- -
- -
-
- -

{{ t.settings_section_storage }}

-
-

{{ t.settings_r2_help }}

-

{{ t.settings_r2_migration_help }}

-
-
- -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- -

{{ t.settings_section_notifications }}

-
- -

{{ t.settings_client_notifications_help }}

-
-
+
+

Web Push / VAPID

+

{{ t.settings_vapid_warning }}

-

{{ t.settings_vapid_generate }}

- cargo run --bin generate_vapid -
-
- -
- +

{{ t.settings_vapid_generate }} cargo run --bin generate_vapid

+
+
+
+ +
+ +
+
+
+ +
+ +
+

{% if vapid_private_key_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}

+ {% if vapid_private_key_configured %} + + {% endif %} +
+
+ +
+ +
-
- -
- -
-
-
- -
- -
-
-
- - {{ t.settings_push_subscribers }} ({{ push_subscribers.len() }}) - -
+ +
+ {{ t.settings_push_subscribers }} {{ push_subscribers.len() }} +
{% if push_subscribers.is_empty() %} -

{{ t.settings_push_no_subscribers }}

+

{{ t.settings_push_no_subscribers }}

{% else %} - - - - - - - - - {% for subscriber in &push_subscribers %} - - - - - - - {% endfor %} - -
{{ t.settings_push_client }}{{ t.settings_push_devices }}{{ t.settings_push_language }}{{ t.settings_push_updated }}
{{ subscriber.client_name }}{{ subscriber.device_count }}{{ subscriber.languages }}{{ subscriber.last_updated }}
+
+ + + + + + + + + {% for subscriber in &push_subscribers %} + + + + + + + {% endfor %} + +
{{ t.settings_push_client }}{{ t.settings_push_devices }}{{ t.settings_push_language }}{{ t.settings_push_updated }}
{{ subscriber.client_name }}{{ subscriber.device_count }}{{ subscriber.languages }}{{ subscriber.last_updated }}
+
{% endif %}
-
- -
- -
-
+
-

{{ t.settings_section_captcha }}

-
- -
- +
+

Telegram

+
+ +
+
-
-
- -
- -
-
- -

{{ t.settings_section_oidc }}

-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
-
- +

{% if telegram_bot_token_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}

+ {% if telegram_bot_token_configured %} + + {% endif %}
-
+
+ - - -
+
+
+ +
+

{{ t.settings_section_captcha }}

+

{{ t.settings_section_captcha_help }}

+
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+

{% if turnstile_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}

+ {% if turnstile_secret_configured %} + + {% endif %} +
+
+
+
+ +
+
+ +
+

{{ t.settings_section_oidc }}

+

{{ t.settings_section_oidc_help }}

+
+
+
+
+ + +
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+

{% if oidc_client_secret_configured %}{{ t.settings_secret_saved }}{% else %}{{ t.settings_secret_not_set }}{% endif %}

+ {% if oidc_client_secret_configured %} + + {% endif %} +
+
+ +
+ +
+
+
+
+
+ +
+ +
+ + + {% endblock %}