From 9d5891963a58ca9e97f47b80700618cdc72f2224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 2 Jun 2025 22:02:24 +0200 Subject: [PATCH 01/87] Translated using Weblate (Estonian) Currently translated at 44.1% (179 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/ --- web/public/static/langs/et.json | 80 ++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/et.json b/web/public/static/langs/et.json index 7d2a5a85..eb0295e7 100644 --- a/web/public/static/langs/et.json +++ b/web/public/static/langs/et.json @@ -99,5 +99,83 @@ "account_tokens_table_create_token_button": "Loo ligipääsuks vajalik tunnusluba", "account_tokens_dialog_title_create": "Loo ligipääsuks vajalik tunnusluba", "account_tokens_dialog_title_edit": "Muuda ligipääsuks vajalikku tunnusluba", - "account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba" + "account_tokens_dialog_title_delete": "Kustuta ligipääsuks vajalik tunnusluba", + "subscribe_dialog_login_password_label": "Salasõna", + "publish_dialog_filename_label": "Failinimi", + "prefs_reservations_table_access_header": "Ligipääs", + "publish_dialog_chip_click_label": "Klõpsi võrguaadressi", + "subscribe_dialog_subscribe_button_cancel": "Katkesta", + "publish_dialog_delay_label": "Viivitus", + "account_basics_password_title": "Salasõna", + "account_upgrade_dialog_button_cancel": "Katkesta", + "notifications_example": "Näide", + "account_usage_title": "Kasutus", + "account_basics_title": "Kasutajakonto", + "prefs_reservations_table_topic_header": "Teema", + "account_delete_dialog_button_cancel": "Katkesta", + "account_delete_dialog_label": "Salasõna", + "publish_dialog_message_label": "Sõnum", + "account_basics_phone_numbers_dialog_channel_call": "Kõne", + "prefs_users_dialog_password_label": "Salasõna", + "subscribe_dialog_subscribe_button_subscribe": "Telli", + "publish_dialog_priority_label": "Prioriteet", + "subscribe_dialog_login_button_login": "Logi sisse", + "subscribe_dialog_error_user_anonymous": "anonüümne", + "prefs_appearance_theme_title": "Kujundus", + "publish_dialog_button_cancel": "Katkesta", + "account_usage_unlimited": "Piiramatu", + "prefs_notifications_delete_after_never": "Mitte kunagi", + "account_upgrade_dialog_interval_monthly": "Iga kuu", + "account_upgrade_dialog_tier_price_per_month": "kuu", + "prefs_notifications_web_push_disabled": "Pole kasutusel", + "prefs_appearance_title": "Välimus", + "prefs_appearance_language_title": "Keel", + "prefs_reservations_dialog_topic_label": "Teema", + "publish_dialog_priority_min": "Väikseim tähtsus", + "notifications_actions_failed_notification": "Ebaõnnestunud toiming", + "publish_dialog_title_label": "Pealkiri", + "publish_dialog_tags_label": "Sildid", + "publish_dialog_email_label": "E-post", + "display_name_dialog_placeholder": "Kuvatav nimi", + "publish_dialog_title_no_topic": "Avalda teavitus", + "publish_dialog_progress_uploading": "Laadin üles…", + "publish_dialog_message_published": "Teavitus on saadetud", + "publish_dialog_emoji_picker_show": "Vali emoji", + "publish_dialog_priority_low": "Vähetähtis", + "publish_dialog_priority_default": "Vaikimisi tähtsus", + "publish_dialog_priority_high": "Oluline", + "publish_dialog_priority_max": "Väga oluline", + "publish_dialog_base_url_label": "Teenuse võrguaadress", + "publish_dialog_topic_label": "Teema nimi", + "publish_dialog_topic_reset": "Lähtesta teema", + "publish_dialog_click_label": "Klõpsi võrguaadressi", + "publish_dialog_call_label": "Telefonikõne", + "publish_dialog_button_send": "Saada", + "publish_dialog_attach_label": "Manuse võrguaadress", + "publish_dialog_filename_placeholder": "Manuse failinimi", + "publish_dialog_other_features": "Lisavõimalused:", + "publish_dialog_chip_call_label": "Telefonikõne", + "publish_dialog_chip_delay_label": "Viivita saatmisega", + "publish_dialog_chip_topic_label": "Muuda teemat", + "publish_dialog_button_cancel_sending": "Katkesta saatmine", + "account_basics_username_title": "Kasutajanimi", + "account_basics_phone_numbers_dialog_channel_sms": "Tekstisõnum", + "account_basics_tier_admin": "Peakasutaja", + "account_basics_tier_basic": "Baasteenus", + "account_basics_tier_free": "Tasuta", + "account_basics_tier_interval_monthly": "kord kuus", + "account_basics_tier_interval_yearly": "kord aastas", + "account_basics_tier_change_button": "Muuda", + "account_upgrade_dialog_interval_yearly": "Kord aastas", + "account_upgrade_dialog_tier_selected_label": "Valitud", + "account_upgrade_dialog_tier_current_label": "Praegune", + "account_tokens_dialog_button_cancel": "Katkesta", + "prefs_notifications_title": "Teavitused", + "prefs_users_table_user_header": "Kasutaja", + "prefs_reservations_dialog_access_label": "Ligipääs", + "priority_min": "min", + "priority_low": "madal", + "priority_default": "vaikimisi", + "priority_high": "kõrge", + "priority_max": "kõrgeim" } From 30301c8a7ff9e54ae505daf73a7f1571e7fefae3 Mon Sep 17 00:00:00 2001 From: "Philipp C. Heckel" Date: Sat, 7 Jun 2025 06:49:22 -0400 Subject: [PATCH 02/87] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 07d983f6..61591ca6 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ account costs. Even small donations are very much appreciated. Thank you to our commercial sponsors, who help keep the service running and the development going: -
- + + + And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy: From 86bec660bf70753b9265bc822063cbbbbc92d9f3 Mon Sep 17 00:00:00 2001 From: lazar Date: Sun, 8 Jun 2025 00:27:42 +0200 Subject: [PATCH 03/87] Translated using Weblate (Romanian) Currently translated at 60.2% (244 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ro/ --- web/public/static/langs/ro.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ro.json b/web/public/static/langs/ro.json index df000eca..c309932c 100644 --- a/web/public/static/langs/ro.json +++ b/web/public/static/langs/ro.json @@ -228,5 +228,19 @@ "account_basics_password_dialog_new_password_label": "Parola nouă", "account_basics_password_title": "Parolă", "account_basics_tier_description": "Nivelul de putere al contului", - "account_basics_tier_free": "Gratuit" + "account_basics_tier_free": "Gratuit", + "account_delete_description": "Șterge definitiv contul tău", + "account_usage_messages_title": "Mesaje publicate", + "account_basics_tier_manage_billing_button": "Gestionare facturare", + "account_usage_emails_title": "Emailuri trimise", + "account_usage_calls_title": "Apeluri telefonice efectuate", + "account_usage_calls_none": "Nu se pot efectua apeluri telefonice cu acest cont", + "account_usage_reservations_title": "Subiecte rezervate", + "account_usage_cannot_create_portal_session": "Nu s-a putut deschide portalul de facturare", + "account_delete_title": "Șterge contul", + "account_usage_attachment_storage_description": "{{filesize}} per fișier, șters după {{expiry}}", + "account_usage_attachment_storage_title": "Stocare atașamente", + "account_usage_basis_ip_description": "Statistica și limitele de utilizare pentru acest cont se bazează pe adresa ta IP, așadar pot fi partajate cu alți utilizatori. Limitele afișate mai sus sunt aproximative, bazate pe limitele de viteză existente.", + "account_usage_reservations_none": "Nu există subiecte rezervate pentru acest cont", + "account_basics_tier_canceled_subscription": "Abonamentul tău a fost anulat și va fi retrogradat la un cont gratuit în data de {{date}}." } From a41e3a1e768b92014c7eecdd26700edb3bc54f15 Mon Sep 17 00:00:00 2001 From: Niko Diamadis Date: Fri, 20 Jun 2025 00:45:42 +0200 Subject: [PATCH 04/87] Update App Store badges and remove Docker compose versions --- docker-compose.yml | 2 -- docs/config.md | 2 -- docs/index.md | 6 +++--- docs/install.md | 2 -- docs/static/img/badge-appstore.png | Bin 5922 -> 24487 bytes docs/static/img/badge-fdroid.png | Bin 4524 -> 17302 bytes docs/static/img/badge-googleplay.png | Bin 3812 -> 4698 bytes docs/subscribe/phone.md | 6 +++--- 8 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d39492e8..d634600c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "2.1" services: ntfy: image: binwiederhier/ntfy @@ -14,4 +13,3 @@ services: ports: - 80:80 restart: unless-stopped - diff --git a/docs/config.md b/docs/config.md index 1687c2ec..74d1d1f4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -79,7 +79,6 @@ using Docker Compose (i.e. `docker-compose.yml`): === "Docker Compose (w/ auth, cache, attachments)" ``` yaml - version: '3' services: ntfy: image: binwiederhier/ntfy @@ -101,7 +100,6 @@ using Docker Compose (i.e. `docker-compose.yml`): === "Docker Compose (w/ auth, cache, web push, iOS)" ``` yaml - version: '3' services: ntfy: image: binwiederhier/ntfy diff --git a/docs/index.md b/docs/index.md index c63b7709..307463ed 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,9 +3,9 @@ ntfy lets you **send push notifications to your phone or desktop via scripts fro or POST requests. I use it to notify myself when scripts fail, or long-running commands complete. ## Step 1: Get the app - - - + + + To [receive notifications on your phone](subscribe/phone.md), install the app, either via Google Play, App Store or F-Droid. Once installed, open it and subscribe to a topic of your choosing. Topics don't have to explicitly be created, so just diff --git a/docs/install.md b/docs/install.md index e71bac52..42c868fc 100644 --- a/docs/install.md +++ b/docs/install.md @@ -280,8 +280,6 @@ docker run \ Using docker-compose with non-root user and healthchecks enabled: ```yaml -version: "2.3" - services: ntfy: image: binwiederhier/ntfy diff --git a/docs/static/img/badge-appstore.png b/docs/static/img/badge-appstore.png index 0b4ce1c06dd4973b32c2ada5a2d6db3b044a29cd..a8bc73a6f005fb42ebb2736ecfcac2b67bb37fd5 100644 GIT binary patch literal 24487 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 literal 5922 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 Ef;P&bCjbBd diff --git a/docs/static/img/badge-fdroid.png b/docs/static/img/badge-fdroid.png index 9464d38a13c4845e51160d126e8a4ba2b922c9d9..c1fc8492a9071e39a5df2ce92a315546609c3c7f 100644 GIT binary patch literal 17302 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 KMNUMnLSTZ&KbeI9 diff --git a/docs/static/img/badge-googleplay.png b/docs/static/img/badge-googleplay.png index 36036d8bdc64bdc8adf32905719fef19362fef67..7a06997a57236e18b5bd2a152435911b72371a89 100644 GIT binary patch literal 4698 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 ab}JoSS9!w3tD~s^0000 - - + + + You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that From 994266ab04ad2b8286bc8155cc142a743f2de85a Mon Sep 17 00:00:00 2001 From: Joan Date: Fri, 20 Jun 2025 12:07:37 +0200 Subject: [PATCH 05/87] Added translation using Weblate (Catalan) --- web/public/static/langs/ca.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/ca.json diff --git a/web/public/static/langs/ca.json b/web/public/static/langs/ca.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/ca.json @@ -0,0 +1 @@ +{} From 62c8a13ed4384486ce2f83ff4773c9e8f7616c0b Mon Sep 17 00:00:00 2001 From: Joan Date: Fri, 20 Jun 2025 12:08:54 +0200 Subject: [PATCH 06/87] Translated using Weblate (Catalan) Currently translated at 1.2% (5 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/ --- web/public/static/langs/ca.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ca.json b/web/public/static/langs/ca.json index 0967ef42..0d8b4bea 100644 --- a/web/public/static/langs/ca.json +++ b/web/public/static/langs/ca.json @@ -1 +1,7 @@ -{} +{ + "nav_button_documentation": "Documentació", + "action_bar_profile_title": "Perfil", + "action_bar_settings": "Configuració", + "action_bar_account": "Compte", + "common_add": "Afegir" +} From c1e657db8b123113789ae53dbec23f01194724a0 Mon Sep 17 00:00:00 2001 From: Kachelkaiser Date: Mon, 23 Jun 2025 17:08:27 +0200 Subject: [PATCH 07/87] Translated using Weblate (German) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 92dec374..88543c5e 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -31,7 +31,7 @@ "notifications_attachment_open_title": "Gehe zu {{url}}", "notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.", "action_bar_send_test_notification": "Test-Benachrichtigung senden", - "alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.", + "alert_notification_permission_required_description": "Browser erlauben, Desktop-Benachrichtigungen anzuzeigen", "notifications_tags": "Tags", "message_bar_type_message": "Gib hier eine Nachricht ein", "message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung", @@ -208,11 +208,11 @@ "action_bar_change_display_name": "Anzeigenamen ändern", "action_bar_reservation_add": "Thema reservieren", "action_bar_reservation_edit": "Reservierung ändern", - "action_bar_reservation_delete": "Reservierung löschen", + "action_bar_reservation_delete": "Reservierung entfernen", "action_bar_reservation_limit_reached": "Grenze erreicht", "action_bar_profile_title": "Profil", "action_bar_profile_settings": "Einstellungen", - "action_bar_profile_logout": "Abmelden", + "action_bar_profile_logout": "Ausloggen", "action_bar_sign_in": "Anmelden", "signup_form_password": "Kennwort", "signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten", @@ -382,7 +382,7 @@ "account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag", "action_bar_mute_notifications": "Benachrichtigungen stummschalten", - "action_bar_unmute_notifications": "Benachrichtigungen laut schalten", + "action_bar_unmute_notifications": "Benachrichtigungen einschalten", "alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert", "alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser", "notifications_actions_failed_notification": "Aktion nicht erfolgreich", @@ -402,6 +402,6 @@ "web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert", "web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten", "web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom server empfangen", - "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.", + "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.", "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)" } From df73c6f655502100179c7978a4265e038f060792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 24 Jun 2025 01:19:39 +0200 Subject: [PATCH 08/87] Translated using Weblate (Estonian) Currently translated at 52.5% (213 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/ --- web/public/static/langs/et.json | 36 ++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/et.json b/web/public/static/langs/et.json index eb0295e7..bf1fe734 100644 --- a/web/public/static/langs/et.json +++ b/web/public/static/langs/et.json @@ -177,5 +177,39 @@ "priority_low": "madal", "priority_default": "vaikimisi", "priority_high": "kõrge", - "priority_max": "kõrgeim" + "priority_max": "kõrgeim", + "alert_notification_ios_install_required_description": "Teavituste lubamiseks iOS-is klõpsi „Jaga“ ikooni ja vali „Lisa avaekraanile“", + "notifications_none_for_topic_title": "Sul pole selles teemas veel ühtegi teavitust.", + "notifications_none_for_topic_description": "Selles teemas teavituste saatmiseks tee PUT või POST meetodiga päring teema võrguaadressile.", + "publish_dialog_base_url_placeholder": "Teenuse võrguaadress, nt. https://toresait.com", + "notifications_loading": "Laadin teavitusi…", + "publish_dialog_title_topic": "Avalda teemas {{topic}}", + "publish_dialog_progress_uploading_detail": "Üleslaadimisel {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_topic_placeholder": "Teema nimi, nt. kati_teavitused", + "publish_dialog_title_placeholder": "Teavituse pealkiri, nt. Andmeruumi teavitus", + "publish_dialog_message_placeholder": "Siia sisesta sõnum", + "notifications_none_for_any_title": "Sa pole veel saanud ühtegi teavitust.", + "publish_dialog_chip_attach_file_label": "Lisa kohalik fail", + "publish_dialog_chip_attach_url_label": "Lisa fail võrguaadressilt", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Kinnitatud telefoninumbreid ei leidu", + "publish_dialog_chip_email_label": "Edasta e-posti aadressile", + "subscribe_dialog_subscribe_base_url_label": "Teenuse võrguaadress", + "subscribe_dialog_subscribe_button_generate_topic_name": "Loo nimi", + "publish_dialog_checkbox_markdown": "Kasuta Markdown-vormingut", + "subscribe_dialog_login_title": "Vajalik on sisselogimine", + "subscribe_dialog_login_username_label": "Kasutajanimi, nt. kadri", + "account_basics_phone_numbers_dialog_verify_button_sms": "Saada SMS", + "account_basics_username_description": "Hei, see oled sina ❤", + "account_basics_username_admin_tooltip": "Sina oled peakasutaja", + "account_basics_phone_numbers_dialog_verify_button_call": "Helista mulle", + "account_basics_phone_numbers_dialog_code_label": "Kinnituskood", + "account_basics_phone_numbers_dialog_code_placeholder": "nt. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Korda koodi", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} sõnum päevas", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} sõnumit päevas", + "account_upgrade_dialog_button_redirect_signup": "Liitu kohe", + "notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas", + "alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on Teavituste API piirang." } From 9c8a8f87959da389b2f68e780b76a2a0a3d78989 Mon Sep 17 00:00:00 2001 From: "huy.phan" Date: Thu, 26 Jun 2025 15:04:43 +0200 Subject: [PATCH 09/87] Translated using Weblate (Vietnamese) Currently translated at 20.0% (81 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/vi/ --- web/public/static/langs/vi.json | 60 ++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/web/public/static/langs/vi.json b/web/public/static/langs/vi.json index 6167c4bc..cd1ad455 100644 --- a/web/public/static/langs/vi.json +++ b/web/public/static/langs/vi.json @@ -5,10 +5,10 @@ "signup_form_toggle_password_visibility": "Hiện mật khẩu", "login_form_button_submit": "Đăng nhập", "common_copy_to_clipboard": "Lưu vào clipboard", - "signup_form_username": "Tên user", + "signup_form_username": "Tên đăng nhập", "signup_already_have_account": "Đã có tài khoản? Đăng nhập!", - "signup_disabled": "Đăng kí bị đóng", - "signup_error_username_taken": "Tên {{username}} đã được sử dụng", + "signup_disabled": "Đăng kí bị khoá", + "signup_error_username_taken": "Tên đăng nhập {{username}} đã được sử dụng", "signup_error_creation_limit_reached": "Đã đạt giới hạn tạo tài khoản", "login_title": "Đăng nhập vào tài khoản ntfy", "login_link_signup": "Đăng kí", @@ -27,5 +27,57 @@ "action_bar_unsubscribe": "Hủy đăng kí", "action_bar_unmute_notifications": "Bật thông báo", "action_bar_toggle_mute": "Bật/tắt thông báo", - "action_bar_mute_notifications": "Tắt thông báo" + "action_bar_mute_notifications": "Tắt thông báo", + "common_save": "Lưu", + "common_cancel": "Hủy", + "nav_button_all_notifications": "Tất cả thông báo", + "nav_button_connecting": "đang kết nối", + "nav_upgrade_banner_label": "Nâng cấp tài khoản ntfy Pro", + "alert_not_supported_title": "Thông báo không được hỗ trợ", + "alert_not_supported_description": "Thông báo không được hỗ trợ trên trình duyệt của bạn", + "notifications_list": "Danh sách thông báo", + "notifications_list_item": "Thông báo", + "notifications_mark_read": "Đánh dấu đã đọc", + "notifications_delete": "Xoá", + "notifications_attachment_copy_url_title": "Sao chép URL đính kèm vào clipboard", + "notifications_attachment_copy_url_button": "Sao chép URL", + "notifications_attachment_open_title": "Truy cập {{url}}", + "notifications_click_copy_url_button": "Sao chép liên kết", + "notifications_click_open_button": "Mở liên kết", + "notifications_actions_not_supported": "Không được hỗ trợ trên nên tảng web", + "notifications_actions_http_request_title": "Gởi HTTP {{method}} tới {{url}}", + "action_bar_profile_settings": "Cài đặt", + "message_bar_type_message": "Gõ nội dung tại đây", + "nav_button_account": "Tài khoản", + "nav_button_settings": "Cài đặt", + "nav_button_documentation": "Tài liệu", + "alert_notification_permission_required_title": "Thông báo đã bị khoá", + "alert_notification_permission_required_button": "Cấp quyền ngay", + "alert_notification_permission_denied_title": "Thông báo đã bị chặn", + "alert_notification_ios_install_required_title": "Yêu cầu cài đặt iOS", + "alert_notification_ios_install_required_description": "Nhấn vào biểu tượng Chia sẻ và Thêm vào màn hình chính để kích hoạt thông báo trên iOS", + "alert_notification_permission_required_description": "Cấp quyền để trình duyệt hiển thị thông báo trên màn hình", + "alert_notification_permission_denied_description": "Hãy kích hoạt lại trên trình duyệt của bạn", + "notifications_copied_to_clipboard": "Đã lưu vào clipboard", + "notifications_attachment_file_video": "tập tin video", + "notifications_attachment_file_audio": "tập tin âm thanh", + "notifications_actions_failed_notification": "Thực thi thất bại", + "notifications_new_indicator": "Thông báo mới", + "notifications_click_copy_url_title": "Sao liên kết URL vào clipboard", + "notifications_actions_open_url_title": "Truy cập {{url}}", + "notifications_priority_x": "Độ ưu tiên {{priority}}", + "notifications_attachment_link_expired": "liên kết tải đã hết hạn", + "notifications_attachment_file_image": "tập tin hình ảnh", + "notifications_tags": "Thẻ", + "notifications_attachment_file_document": "tập tin khác", + "action_bar_sign_in": "Đăng nhập", + "notifications_attachment_image": "Hình ảnh đính kèm", + "action_bar_sign_up": "Đăng ký", + "action_bar_profile_title": "Hồ sơ", + "action_bar_toggle_action_menu": "Mở/Đóng bảng điều khiển", + "action_bar_profile_logout": "Đăng xuất", + "notifications_attachment_file_app": "tập tin Android", + "notifications_attachment_link_expires": "liên kết đã hết hạn {{date}}", + "alert_not_supported_context_description": "Thông báo chỉ được hỗ trợ qua giao thức HTTPS. Đây là hạn chế của API thông báo.", + "notifications_attachment_open_button": "Mở đính kèm" } From 8e7de8035389e9d7610c691670268747b7b053e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Wed, 25 Jun 2025 22:04:39 +0200 Subject: [PATCH 10/87] Translated using Weblate (Estonian) Currently translated at 67.1% (272 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/et/ --- web/public/static/langs/et.json | 61 ++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/et.json b/web/public/static/langs/et.json index bf1fe734..f84ed426 100644 --- a/web/public/static/langs/et.json +++ b/web/public/static/langs/et.json @@ -211,5 +211,64 @@ "notifications_actions_http_request_title": "Tee päring HTTP {{method}}-meetodiga võrguaadressile {{url}}", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} e-kirja päevas", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} e-kiri päevas", - "alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on Teavituste API piirang." + "alert_not_supported_context_description": "Teavitused võivad kasutada vaid HTTPS-ühendust. See on Teavituste API piirang.", + "publish_dialog_tags_placeholder": "Komadega eraldatud siltide loend, nt. hoiatus, srv1-varundus", + "display_name_dialog_title": "Muuda kuvatavat nime", + "display_name_dialog_description": "Lisa teemale alternatiivne nimi, mida kuvatakse tellimuste loendis. See on näiteks abiks keerukate nimedega teemade tuvastamiseks.", + "reserve_dialog_checkbox_label": "Reserveeri teema ja seadista ligipääs", + "publish_dialog_attachment_limits_file_reached": "ületab failisuuruse piiri: {{fileSizeLimit}}", + "publish_dialog_attachment_limits_quota_reached": "ületab kvooti, jäänud on {{remainingBytes}}", + "publish_dialog_attachment_limits_file_and_quota_reached": "ületab failisuuruse ülempiiri ({{fileSizeLimit}}) ja kvooti, jäänud on {{remainingBytes}}", + "publish_dialog_click_placeholder": "Teavituse klõpsimisel avatav võrguaadress", + "publish_dialog_click_reset": "Eemalda klikatav võrguaadress", + "publish_dialog_email_placeholder": "Aadress, kuhu teavitus edastatakse, nt. kadri@torefirma.com", + "publish_dialog_email_reset": "Eemalda edastamiseks kasutatav e-posti aadress", + "publish_dialog_call_item": "Helista telefoninumbrile {{number}}", + "publish_dialog_call_reset": "Eemalda helistamine", + "publish_dialog_attach_placeholder": "Lisa fail võrguaadressilt, nt. https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Eemalda manuse lisamisel kasutatav võrguaadress", + "publish_dialog_delay_reset": "Eemalda viivitus teavituse edastamisel", + "account_basics_password_description": "Muuda oma kasutajakonto salasõna", + "account_basics_password_dialog_title": "Salasõna muutmine", + "account_basics_password_dialog_current_password_label": "Senine salasõna", + "account_basics_password_dialog_button_submit": "Muuda salasõna", + "account_basics_password_dialog_current_password_incorrect": "Salasõna pole korrektne", + "account_basics_phone_numbers_title": "Telefoninumbrid", + "account_basics_phone_numbers_description": "Kõneteavituste jaoks", + "account_basics_tier_title": "Kasutajakonto tüüp", + "account_basics_tier_description": "Sinu kasutajakonto õigused", + "account_delete_dialog_button_submit": "Kustuta kasutajakonto jäädavalt", + "prefs_appearance_theme_system": "Süsteemi kujundus", + "prefs_appearance_theme_dark": "Tume kujundus", + "prefs_appearance_theme_light": "Hele kujundus", + "prefs_reservations_title": "Reserveeritud teemad", + "prefs_users_table": "Kasutajate loend", + "prefs_users_add_button": "Lisa kasutaja", + "prefs_users_edit_button": "Muuda kasutajat", + "prefs_users_delete_button": "Kustuta kasutaja", + "prefs_users_table_cannot_delete_or_edit": "Sisselogitud kasutajat ei saa kustutada ega muuta", + "prefs_users_table_base_url_header": "Teenuse võrguaadress", + "prefs_users_dialog_title_add": "Lisa kasutaja", + "prefs_users_dialog_title_edit": "Muuda kasutajat", + "prefs_users_dialog_base_url_label": "Teenuse võrguaadress, nt. https://ntfy.sh", + "prefs_users_dialog_username_label": "Kasutajanimi, nt. kadri", + "prefs_notifications_delete_after_three_hours": "Kolme tunni möödumisel", + "prefs_notifications_delete_after_three_hours_description": "Teavitused kustutatakse automaatselt kolme tunni möödumisel", + "prefs_notifications_delete_after_one_day_description": "Teavitused kustutatakse automaatselt ühe päeva möödumisel", + "prefs_notifications_delete_after_one_week_description": "Teavitused kustutatakse automaatselt ühe nädala möödumisel", + "prefs_notifications_delete_after_one_month_description": "Teavitused kustutatakse automaatselt ühe kuu möödumisel", + "prefs_notifications_delete_after_never_description": "Mitte kunagi ei kustutata teavitusi automaatselt", + "prefs_notifications_delete_after_title": "Kustuta teavitused", + "publish_dialog_delay_placeholder": "Viivitus teavituse edastamisel, nt. {{unixTimestamp}}, {{relativeTime}} või „{{naturalLanguage}}“ (vaid inglise keeles)", + "account_basics_password_dialog_new_password_label": "Uus salasõna", + "account_basics_password_dialog_confirm_password_label": "Korda salasõna", + "account_basics_phone_numbers_dialog_description": "Kõneteavituse kasutamiseks pead lisama ja kinnitama vähemalt ühe telefoninumbri. Kinnitamist saad teha SMS-i või kõne abil.", + "account_basics_phone_numbers_dialog_number_placeholder": "nt. +37256123456", + "account_basics_phone_numbers_no_phone_numbers_yet": "Telefoninumbreid veel pole", + "account_basics_phone_numbers_copied_to_clipboard": "Telefoninumber on kopeeritud lõikelauale", + "account_basics_phone_numbers_dialog_title": "Lisa telefoninumber", + "account_basics_phone_numbers_dialog_number_label": "Telefoninumber", + "prefs_notifications_delete_after_one_week": "Ühe nädala möödumisel", + "prefs_notifications_delete_after_one_day": "Ühe päeva möödumisel", + "prefs_notifications_delete_after_one_month": "Ühe kuu möödumisel" } From ff904a5ca62b8fe2dc8d82f9f21a16df9473634e Mon Sep 17 00:00:00 2001 From: Carl Fritze Date: Mon, 30 Jun 2025 14:29:37 +0200 Subject: [PATCH 11/87] Translated using Weblate (German) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 88543c5e..9aeedb95 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -36,7 +36,7 @@ "message_bar_type_message": "Gib hier eine Nachricht ein", "message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung", "alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt", - "alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt", + "alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt:", "action_bar_settings": "Einstellungen", "action_bar_clear_notifications": "Alle Benachrichtigungen löschen", "alert_notification_permission_required_button": "Jetzt erlauben", @@ -401,7 +401,7 @@ "error_boundary_button_reload_ntfy": "ntfy neu laden", "web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert", "web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten", - "web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom server empfangen", + "web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen", "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.", "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)" } From 48cb816111fb814f70ce24b9c9301f2a521c0b6d Mon Sep 17 00:00:00 2001 From: Kachelkaiser Date: Mon, 30 Jun 2025 14:31:10 +0200 Subject: [PATCH 12/87] Translated using Weblate (German) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 9aeedb95..95b7a1fc 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -36,7 +36,7 @@ "message_bar_type_message": "Gib hier eine Nachricht ein", "message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung", "alert_not_supported_title": "Benachrichtigungen werden nicht unterstützt", - "alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt:", + "alert_not_supported_description": "Benachrichtigungen werden von deinem Browser nicht unterstützt", "action_bar_settings": "Einstellungen", "action_bar_clear_notifications": "Alle Benachrichtigungen löschen", "alert_notification_permission_required_button": "Jetzt erlauben", @@ -382,7 +382,7 @@ "account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag", "action_bar_mute_notifications": "Benachrichtigungen stummschalten", - "action_bar_unmute_notifications": "Benachrichtigungen einschalten", + "action_bar_unmute_notifications": "Stummschaltung von Benachrichtigungen aufheben", "alert_notification_permission_denied_title": "Benachrichtigungen sind blockiert", "alert_notification_permission_denied_description": "Bitte reaktiviere diese in deinem Browser", "notifications_actions_failed_notification": "Aktion nicht erfolgreich", @@ -390,8 +390,8 @@ "alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren", "subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist", "publish_dialog_checkbox_markdown": "Als Markdown formatieren", - "prefs_notifications_web_push_title": "Hintergrund-Benachrichtigungen", - "prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen wenn die Web App läuft (über WebSocket)", + "prefs_notifications_web_push_title": "Hintergrundbenachrichtigung", + "prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)", "prefs_notifications_web_push_enabled": "Aktiviert für {{server}}", "prefs_notifications_web_push_disabled": "Deaktiviert", "prefs_appearance_theme_title": "Thema", @@ -402,6 +402,6 @@ "web_push_subscription_expiring_title": "Benachrichtigungen werden pausiert", "web_push_subscription_expiring_body": "Öffne ntfy um weiterhin Benachrichtigungen zu erhalten", "web_push_unknown_notification_title": "Unbekannte Benachrichtigung vom Server empfangen", - "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest.", - "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht läuft (über Web Push)" + "web_push_unknown_notification_body": "Du musst möglicherweise ntfy aktualisieren, indem du die Web App öffnest", + "prefs_notifications_web_push_enabled_description": "Benachrichtigungen werden empfangen, auch wenn die Web App nicht geöffnet ist (via Web Push)" } From ae27c3a5ab4d07fa11104d690d88257be3ffe884 Mon Sep 17 00:00:00 2001 From: Kachelkaiser Date: Mon, 30 Jun 2025 14:31:59 +0200 Subject: [PATCH 13/87] Translated using Weblate (German) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 95b7a1fc..0654483a 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -390,7 +390,7 @@ "alert_notification_ios_install_required_description": "Klicke auf das Teilen-Symbol und “Zum Home-Bildschirm” um auf iOS Benachrichtigungen zu aktivieren", "subscribe_dialog_subscribe_use_another_background_info": "Benachrichtigungen von anderen Servern werden nicht empfangen, wenn die Web App nicht geöffnet ist", "publish_dialog_checkbox_markdown": "Als Markdown formatieren", - "prefs_notifications_web_push_title": "Hintergrundbenachrichtigung", + "prefs_notifications_web_push_title": "Hintergrundbenachrichtigungen", "prefs_notifications_web_push_disabled_description": "Benachrichtigungen werden empfangen, wenn die Web App geöffnet ist (via WebSocket)", "prefs_notifications_web_push_enabled": "Aktiviert für {{server}}", "prefs_notifications_web_push_disabled": "Deaktiviert", From d8c8f318464be290f9b9a44b2d89e140857d7fa2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 4 Jul 2025 07:38:58 +0200 Subject: [PATCH 14/87] IPv6 WIP --- cmd/serve.go | 17 +++++++++-------- server/config.go | 6 +++--- server/smtp_server.go | 11 ++++++----- server/util.go | 23 +++++++++++++++-------- server/visitor.go | 6 ++++++ 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 576e72f0..373ba69e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -5,13 +5,6 @@ package cmd import ( "errors" "fmt" - "github.com/stripe/stripe-go/v74" - "github.com/urfave/cli/v2" - "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/server" - "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" "io/fs" "math" "net" @@ -22,6 +15,14 @@ import ( "strings" "syscall" "time" + + "github.com/stripe/stripe-go/v74" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/server" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" ) func init() { @@ -473,7 +474,7 @@ func sigHandlerConfigReload(config string) { } func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { - // Try parsing as prefix, e.g. 10.0.1.0/24 + // Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32 prefix, err := netip.ParsePrefix(host) if err == nil { prefixes = append(prefixes, prefix.Masked()) diff --git a/server/config.go b/server/config.go index 75e6d488..f3320c5b 100644 --- a/server/config.go +++ b/server/config.go @@ -143,9 +143,9 @@ type Config struct { VisitorAuthFailureLimitReplenish time.Duration VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics - BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address - ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" - ProxyTrustedAddresses []string // List of trusted proxy addresses that will be stripped from the Forwarded header if BehindProxy is true + BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) + ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) + ProxyTrustedAddresses []string // List of trusted proxy addresses (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true StripeSecretKey string StripeWebhookKey string StripePriceCacheDuration time.Duration diff --git a/server/smtp_server.go b/server/smtp_server.go index 6efd5230..6de42e37 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -5,8 +5,6 @@ import ( "encoding/base64" "errors" "fmt" - "github.com/emersion/go-smtp" - "github.com/microcosm-cc/bluemonday" "io" "mime" "mime/multipart" @@ -18,6 +16,9 @@ import ( "regexp" "strings" "sync" + + "github.com/emersion/go-smtp" + "github.com/microcosm-cc/bluemonday" ) var ( @@ -191,9 +192,9 @@ func (s *smtpSession) publishMessage(m *message) error { // Call HTTP handler with fake HTTP request url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) - req.RequestURI = "/" + m.Topic // just for the logs - req.RemoteAddr = remoteAddr // rate limiting!! - req.Header.Set("X-Forwarded-For", remoteAddr) + req.RequestURI = "/" + m.Topic // just for the logs + req.RemoteAddr = remoteAddr // rate limiting!! + req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header if err != nil { return err } diff --git a/server/util.go b/server/util.go index 3db9e322..54c1851b 100644 --- a/server/util.go +++ b/server/util.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "heckel.io/ntfy/v2/util" "io" "mime" "net/http" @@ -12,6 +11,8 @@ import ( "regexp" "slices" "strings" + + "heckel.io/ntfy/v2/util" ) var ( @@ -20,8 +21,9 @@ var ( // priorityHeaderIgnoreRegex matches specific patterns of the "Priority" header (RFC 9218), so that it can be ignored priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`) - // forwardedHeaderRegex parses IPv4 addresses from the "Forwarded" header (RFC 7239) - forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"?`) + // forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239) + // IPv6 addresses in Forwarded header are enclosed in square brackets, e.g. for="[2001:db8::1]" + forwardedHeaderRegex = regexp.MustCompile(`(?i)\\bfor=\"?((?:[0-9]{1,3}\.){3}[0-9]{1,3}|\[[0-9a-fA-F:]+\])\"?`) ) func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { @@ -103,7 +105,7 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st // then take the right-most address in the list (as this is the one added by our proxy server). // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) { - value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader))) + value := strings.TrimSpace(r.Header.Get(forwardedHeader)) if value == "" { return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader) } @@ -111,12 +113,17 @@ func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trusted addrsStrs := util.Map(util.SplitNoEmpty(value, ","), strings.TrimSpace) var validAddrs []netip.Addr for _, addrStr := range addrsStrs { - if addr, err := netip.ParseAddr(addrStr); err == nil { - validAddrs = append(validAddrs, addr) - } else if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 { - if addr, err := netip.ParseAddr(m[1]); err == nil { + // Handle Forwarded header with for="[IPv6]" or for="IPv4" + if m := forwardedHeaderRegex.FindStringSubmatch(addrStr); len(m) == 2 { + addrRaw := m[1] + if strings.HasPrefix(addrRaw, "[") && strings.HasSuffix(addrRaw, "]") { + addrRaw = addrRaw[1 : len(addrRaw)-1] + } + if addr, err := netip.ParseAddr(addrRaw); err == nil { validAddrs = append(validAddrs, addr) } + } else if addr, err := netip.ParseAddr(addrStr); err == nil { + validAddrs = append(validAddrs, addr) } } // Filter out proxy addresses diff --git a/server/visitor.go b/server/visitor.go index d542e773..155d7be0 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -528,5 +528,11 @@ func visitorID(ip netip.Addr, u *user.User) string { if u != nil && u.Tier != nil { return fmt.Sprintf("user:%s", u.ID) } + if ip.Is6() { + // IPv6 addresses are too long to be used as visitor IDs, so we use the first 8 bytes + ip = netip.PrefixFrom(ip, 64).Masked().Addr() + } else if ip.Is4() { + ip = netip.PrefixFrom(ip, 20).Masked().Addr() + } return fmt.Sprintf("ip:%s", ip.String()) } From 54514454bf5f65c7610edbc3f3003ccdaa30c4f9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 4 Jul 2025 10:16:49 +0200 Subject: [PATCH 15/87] Works --- cmd/serve.go | 16 ++++++-- server/config.go | 6 +++ server/server.go | 2 +- server/server_test.go | 92 +++++++++++++++++++++++++++++++++++++++++-- server/util.go | 11 ++++-- server/visitor.go | 18 ++++----- 6 files changed, 125 insertions(+), 20 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 373ba69e..27ae0fcb 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -80,6 +80,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), + altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), @@ -88,7 +89,8 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), - altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}), @@ -192,6 +194,8 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") + visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") + visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") proxyForwardedHeader := c.String("proxy-forwarded-header") proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",") @@ -325,6 +329,10 @@ func execServe(c *cli.Context) error { return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") } else if behindProxy && proxyForwardedHeader == "" { return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set") + } else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 { + return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") + } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { + return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") } // Backwards compatibility @@ -413,6 +421,7 @@ func execServe(c *cli.Context) error { conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit + conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst @@ -421,7 +430,8 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting + conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 + conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyTrustedAddresses = proxyTrustedAddresses @@ -434,7 +444,6 @@ func execServe(c *cli.Context) error { conf.EnableMetrics = enableMetrics conf.MetricsListenHTTP = metricsListenHTTP conf.ProfileListenHTTP = profileListenHTTP - conf.Version = c.App.Version conf.WebPushPrivateKey = webPushPrivateKey conf.WebPushPublicKey = webPushPublicKey conf.WebPushFile = webPushFile @@ -442,6 +451,7 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration + conf.Version = c.App.Version // Set up hot-reloading of config go sigHandlerConfigReload(config) diff --git a/server/config.go b/server/config.go index f3320c5b..c351ba85 100644 --- a/server/config.go +++ b/server/config.go @@ -61,6 +61,8 @@ const ( DefaultVisitorAuthFailureLimitReplenish = time.Minute DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB + DefaultVisitorPrefixBitsIPv4 = 32 // Use the entire IPv4 address for rate limiting + DefaultVisitorPrefixBitsIPv6 = 64 // Use /64 for IPv6 rate limiting ) var ( @@ -143,6 +145,8 @@ type Config struct { VisitorAuthFailureLimitReplenish time.Duration VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics + VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32) + VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64) BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) ProxyTrustedAddresses []string // List of trusted proxy addresses (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true @@ -234,6 +238,8 @@ func NewConfig() *Config { VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish, VisitorStatsResetTime: DefaultVisitorStatsResetTime, VisitorSubscriberRateLimiting: false, + VisitorPrefixBitsIPv4: 32, // Default: use full IPv4 address + VisitorPrefixBitsIPv6: 64, // Default: use /64 for IPv6 BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs StripeSecretKey: "", diff --git a/server/server.go b/server/server.go index e1126757..8d33d396 100644 --- a/server/server.go +++ b/server/server.go @@ -2023,7 +2023,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor { s.mu.Lock() defer s.mu.Unlock() - id := visitorID(ip, user) + id := visitorID(ip, user, s.config) v, exists := s.visitors[id] if !exists { s.visitors[id] = newVisitor(s.config, s.messageCache, s.userManager, ip, user) diff --git a/server/server_test.go b/server/server_test.go index be0610ac..0a5bcc08 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1169,7 +1169,7 @@ func (t *testMailer) Count() int { return t.count } -func TestServer_PublishTooRequests_Defaults(t *testing.T) { +func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { s := newTestServer(t, newTestConfig(t)) for i := 0; i < 60; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) @@ -1179,7 +1179,50 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) { require.Equal(t, 429, response.Code) } -func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) { +func TestServer_PublishTooManyRequests_Defaults_IPv6(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + overrideRemoteAddr1 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999:8888:1::1]:1234" + } + overrideRemoteAddr2 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999:8888:2::1]:1234" // Same /64 + } + for i := 0; i < 30; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) + require.Equal(t, 200, response.Code) + } + for i := 0; i < 30; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) + require.Equal(t, 429, response.Code) +} + +func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 6 + c.VisitorPrefixBitsIPv6 = 48 // Use /48 for IPv6 prefixes + s := newTestServer(t, c) + overrideRemoteAddr1 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::1]:1234" + } + overrideRemoteAddr2 := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::2]:1234" // Same /48 + } + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr1) + require.Equal(t, 200, response.Code) + } + for i := 0; i < 3; i++ { + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr2) + require.Equal(t, 200, response.Code) + } + response := request(t, s, "PUT", "/mytopic", "message", nil, overrideRemoteAddr1) + require.Equal(t, 429, response.Code) +} + +func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { c := newTestConfig(t) c.VisitorRequestLimitBurst = 3 c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() @@ -1190,7 +1233,21 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) { } } -func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { +func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) { + c := newTestConfig(t) + c.VisitorRequestLimitBurst = 3 + c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} + s := newTestServer(t, c) + overrideRemoteAddr := func(r *http.Request) { + r.RemoteAddr = "[2001:db8:9999::1]:1234" + } + for i := 0; i < 5; i++ { // > 3 + response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil, overrideRemoteAddr) + require.Equal(t, 200, response.Code) + } +} + +func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) { c := newTestConfig(t) c.VisitorRequestLimitBurst = 10 c.VisitorMessageDailyLimit = 4 @@ -1202,7 +1259,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *tes } } -func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) { +func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { t.Parallel() c := newTestConfig(t) c.VisitorRequestLimitBurst = 60 @@ -2244,6 +2301,19 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) { require.Equal(t, "1.2.3.4", v.ip.String()) } +func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "X-Client-IP" + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "[2001:db8:9999::1]:1234" + r.Header.Set("X-Client-IP", "2001:db8:7777::1") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "2001:db8:7777::1", v.ip.String()) +} + func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { c := newTestConfig(t) c.BehindProxy = true @@ -2258,6 +2328,20 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { require.Equal(t, "5.6.7.8", v.ip.String()) } +func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { + c := newTestConfig(t) + c.BehindProxy = true + c.ProxyForwardedHeader = "Forwarded" + c.ProxyTrustedAddresses = []string{"2001:db8:1111::1"} + s := newTestServer(t, c) + r, _ := http.NewRequest("GET", "/bla", nil) + r.RemoteAddr = "[2001:db8:2222::1]:1234" + r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") + v, err := s.maybeAuthenticate(r) + require.Nil(t, err) + require.Equal(t, "2001:db8:3333::1", v.ip.String()) +} + func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { t.Parallel() count := 50000 diff --git a/server/util.go b/server/util.go index 54c1851b..687e7d0e 100644 --- a/server/util.go +++ b/server/util.go @@ -22,8 +22,13 @@ var ( priorityHeaderIgnoreRegex = regexp.MustCompile(`^u=\d,\s*(i|\d)$|^u=\d$`) // forwardedHeaderRegex parses IPv4 and IPv6 addresses from the "Forwarded" header (RFC 7239) - // IPv6 addresses in Forwarded header are enclosed in square brackets, e.g. for="[2001:db8::1]" - forwardedHeaderRegex = regexp.MustCompile(`(?i)\\bfor=\"?((?:[0-9]{1,3}\.){3}[0-9]{1,3}|\[[0-9a-fA-F:]+\])\"?`) + // IPv6 addresses in Forwarded header are enclosed in square brackets. The port is optional. + // + // Examples: + // for="1.2.3.4" + // for="[2001:db8::1]"; for=1.2.3.4:8080, by=phil + // for="1.2.3.4:8080" + forwardedHeaderRegex = regexp.MustCompile(`(?i)\bfor="?(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[[0-9a-f:]+])(?::\d+)?"?`) ) func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { @@ -105,7 +110,7 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st // then take the right-most address in the list (as this is the one added by our proxy server). // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) { - value := strings.TrimSpace(r.Header.Get(forwardedHeader)) + value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader))) if value == "" { return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader) } diff --git a/server/visitor.go b/server/visitor.go index 155d7be0..f26729f1 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -2,13 +2,13 @@ package server import ( "fmt" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/user" "net/netip" "sync" "time" "golang.org/x/time/rate" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -151,7 +151,7 @@ func (v *visitor) Context() log.Context { func (v *visitor) contextNoLock() log.Context { info := v.infoLightNoLock() fields := log.Context{ - "visitor_id": visitorID(v.ip, v.user), + "visitor_id": visitorID(v.ip, v.user, v.config), "visitor_ip": v.ip.String(), "visitor_seen": util.FormatTime(v.seen), "visitor_messages": info.Stats.Messages, @@ -524,15 +524,15 @@ func dailyLimitToRate(limit int64) rate.Limit { return rate.Limit(limit) * rate.Every(oneDay) } -func visitorID(ip netip.Addr, u *user.User) string { +// visitorID returns a unique identifier for a visitor based on user or IP, using configurable prefix bits for IPv4/IPv6 +func visitorID(ip netip.Addr, u *user.User, conf *Config) string { if u != nil && u.Tier != nil { return fmt.Sprintf("user:%s", u.ID) } - if ip.Is6() { - // IPv6 addresses are too long to be used as visitor IDs, so we use the first 8 bytes - ip = netip.PrefixFrom(ip, 64).Masked().Addr() - } else if ip.Is4() { - ip = netip.PrefixFrom(ip, 20).Masked().Addr() + if ip.Is4() { + ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv4).Masked().Addr() + } else if ip.Is6() { + ip = netip.PrefixFrom(ip, conf.VisitorPrefixBitsIPv6).Masked().Addr() } return fmt.Sprintf("ip:%s", ip.String()) } From c99d8b66c279417fd39c04eadd73d66fa1e5ed80 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 4 Jul 2025 10:19:27 +0200 Subject: [PATCH 16/87] Re-order --- server/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/config.go b/server/config.go index c351ba85..0fc39932 100644 --- a/server/config.go +++ b/server/config.go @@ -224,6 +224,7 @@ func NewConfig() *Config { TotalTopicLimit: DefaultTotalTopicLimit, TotalAttachmentSizeLimit: 0, VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, + VisitorSubscriberRateLimiting: false, VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, @@ -237,11 +238,10 @@ func NewConfig() *Config { VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst, VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish, VisitorStatsResetTime: DefaultVisitorStatsResetTime, - VisitorSubscriberRateLimiting: false, - VisitorPrefixBitsIPv4: 32, // Default: use full IPv4 address - VisitorPrefixBitsIPv6: 64, // Default: use /64 for IPv6 - BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address - ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs + VisitorPrefixBitsIPv4: DefaultVisitorPrefixBitsIPv4, // Default: use full IPv4 address + VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6 + BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address + ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs StripeSecretKey: "", StripeWebhookKey: "", StripePriceCacheDuration: DefaultStripePriceCacheDuration, From 7eeaeb839825b9a9d9b405f58b3d008e2da6e153 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 4 Jul 2025 16:51:55 +0200 Subject: [PATCH 17/87] server.yml update --- server/server.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/server.yml b/server/server.yml index 669805b8..37e0faf5 100644 --- a/server/server.yml +++ b/server/server.yml @@ -292,6 +292,18 @@ # visitor-email-limit-burst: 16 # visitor-email-limit-replenish: "1h" +# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting +# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) +# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) +# +# This is used to group visitors by their IP address or subnet. For example, if you set visitor-prefix-bits-ipv4 to 24, +# all visitors in the 1.2.3.0/24 network are treated as one. +# +# By default, ntfy uses the full IPv4 address (32 bits) and the /64 subnet of the IPv6 address (64 bits). +# +# visitor-prefix-bits-ipv4: 32 +# visitor-prefix-bits-ipv6: 64 + # Rate limiting: Attachment size and bandwidth limits per visitor: # - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor # - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor From 60b858812912d3b84ce12fff435b46f585639186 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 4 Jul 2025 16:56:35 +0200 Subject: [PATCH 18/87] Tests --- server/util_test.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/server/util_test.go b/server/util_test.go index 4b60e1a1..2989b0b9 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -4,10 +4,11 @@ import ( "bytes" "crypto/rand" "fmt" - "github.com/stretchr/testify/require" "net/http" "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestReadBoolParam(t *testing.T) { @@ -118,3 +119,27 @@ func TestExtractIPAddress_UnixSocket(t *testing.T) { require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String()) require.Equal(t, "0.0.0.0", extractIPAddress(r, false, "X-Forwarded-For", trustedProxies).String()) } + +func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) { + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) + r.RemoteAddr = "[2001:db8:abcd::1]:1234" + r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8") + trustedProxies := []string{"1.2.3.4"} + require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) +} + +func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) { + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) + r.RemoteAddr = "[2001:db8:abcd::1]:1234" + r.Header.Set("X-Forwarded-For", "2001:db8:abcd::1, 2001:db8:abcd:1::2, 2001:db8:abcd:2::3") + trustedProxies := []string{"2001:db8:abcd::/48"} + require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) +} + +func TestExtractIPAddress_EdgeCases(t *testing.T) { + r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) + r.RemoteAddr = "[::ffff:192.0.2.128]:1234" // IPv4-mapped IPv6 + r.Header.Set("X-Forwarded-For", "::ffff:192.0.2.128, 2001:db8:abcd::1") + trustedProxies := []string{"::ffff:192.0.2.128"} + require.Equal(t, "2001:db8:abcd::1", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) +} From 34e9a771ce8d03c3af6400b964410312d7a4017c Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Sat, 5 Jul 2025 17:05:31 +1000 Subject: [PATCH 19/87] docs: add ntfyexec to integrations --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index c7da21f9..23c5f9e9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -95,6 +95,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11 - [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies. - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. +- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails ## Projects + scripts From 359c789c3406cba8327ba1c008fc9e6b17352913 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 5 Jul 2025 13:11:17 +0200 Subject: [PATCH 20/87] Test for visitorID --- server/util_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/util_test.go b/server/util_test.go index 2989b0b9..128a8160 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -4,7 +4,9 @@ import ( "bytes" "crypto/rand" "fmt" + "heckel.io/ntfy/v2/user" "net/http" + "net/netip" "strings" "testing" @@ -143,3 +145,24 @@ func TestExtractIPAddress_EdgeCases(t *testing.T) { trustedProxies := []string{"::ffff:192.0.2.128"} require.Equal(t, "2001:db8:abcd::1", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) } + +func TestVisitorID(t *testing.T) { + confWithDefaults := &Config{ + VisitorPrefixBitsIPv4: 32, + VisitorPrefixBitsIPv6: 64, + } + confWithShortenedPrefixes := &Config{ + VisitorPrefixBitsIPv4: 16, + VisitorPrefixBitsIPv6: 56, + } + userWithTier := &user.User{ + ID: "u_123", + Tier: &user.Tier{}, + } + require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults)) + require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults)) + require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults)) + require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults)) + require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes)) + require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes)) +} From 677b44ce613389cd0d0f1633a2bba75b3e3a25db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 5 Jul 2025 22:35:26 +0200 Subject: [PATCH 21/87] Docs, rename proxy-trusted-(addresses->hosts) --- cmd/serve.go | 24 +- docs/config.md | 48 ++- docs/releases.md | 3 +- go.mod | 58 +-- go.sum | 122 +++--- server/config.go | 20 +- server/server.go | 6 +- server/server.yml | 4 +- server/server_middleware.go | 4 +- server/server_test.go | 10 +- server/util.go | 14 +- server/util_test.go | 18 +- web/package-lock.json | 716 ++++++++++++++++++------------------ 13 files changed, 532 insertions(+), 515 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 27ae0fcb..ef4d98d5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -93,7 +93,7 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-addresses", Aliases: []string{"proxy_trusted_addresses"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_ADDRESSES"}, Value: "", Usage: "comma-separated list of trusted IP addresses to remove from forwarded header"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), @@ -198,7 +198,7 @@ func execServe(c *cli.Context) error { visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") proxyForwardedHeader := c.String("proxy-forwarded-header") - proxyTrustedAddresses := util.SplitNoEmpty(c.String("proxy-trusted-addresses"), ",") + proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") billingContact := c.String("billing-contact") @@ -358,14 +358,24 @@ func execServe(c *cli.Context) error { } // Resolve hosts - visitorRequestLimitExemptIPs := make([]netip.Prefix, 0) + visitorRequestLimitExemptPrefixes := make([]netip.Prefix, 0) for _, host := range visitorRequestLimitExemptHosts { - ips, err := parseIPHostPrefix(host) + prefixes, err := parseIPHostPrefix(host) if err != nil { log.Warn("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error()) continue } - visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...) + visitorRequestLimitExemptPrefixes = append(visitorRequestLimitExemptPrefixes, prefixes...) + } + + // Parse trusted prefixes + trustedProxyPrefixes := make([]netip.Prefix, 0) + for _, host := range proxyTrustedHosts { + prefixes, err := parseIPHostPrefix(host) + if err != nil { + return fmt.Errorf("cannot resolve trusted proxy host %s: %s", host, err.Error()) + } + trustedProxyPrefixes = append(trustedProxyPrefixes, prefixes...) } // Stripe things @@ -426,7 +436,7 @@ func execServe(c *cli.Context) error { conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish - conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs + conf.VisitorRequestExemptPrefixes = visitorRequestLimitExemptPrefixes conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish @@ -434,7 +444,7 @@ func execServe(c *cli.Context) error { conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader - conf.ProxyTrustedAddresses = proxyTrustedAddresses + conf.ProxyTrustedPrefixes = trustedProxyPrefixes conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey conf.BillingContact = billingContact diff --git a/docs/config.md b/docs/config.md index 1687c2ec..6675b875 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,8 +18,8 @@ get a list of [command line options](#command-line-options). ## Example config !!! info - Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. - It contains examples and detailed descriptions of all the settings. + Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file. It contains examples and detailed descriptions of all the settings. + You may also want to look at how ntfy.sh is configured in the [ntfy-ansible](https://github.com/binwiederhier/ntfy-ansible) repository. The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http` and `listen-https`), and socket path (`listen-unix`). All the other things are additional features. @@ -559,7 +559,17 @@ If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. as the primary identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the -ntfy server, they all share the proxy's IP address. +ntfy server, they all share the proxy's IP address. + +In IPv4 environments, by default, a visitor's IP address is used as-is for rate limiting (**full IPv4 address**). This +means that if a visitor publishes messages from multiple IP addresses, they will be counted as separate visitors. +You can adjust this by setting the `visitor-prefix-bits-ipv4` config option (default is `32`, which is the entire IP address). +To limit visitors to a /24 subnet, for instance, set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor. + +In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that +`2001:db8::1` and `2001:db8::2` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to +adjust this behavior (default is `64`, which is the entire /64 subnet). See [IPv6 considerations](#ipv6-considerations) +for more details. Relevant flags to consider: @@ -568,7 +578,7 @@ Relevant flags to consider: * `proxy-forwarded-header` is the header to use to identify visitors (default: `X-Forwarded-For`). It may be a single IP address (e.g. `1.2.3.4`), a comma-separated list of IP addresses (e.g. `1.2.3.4, 5.6.7.8`), or an [RFC 7239](https://datatracker.ietf.org/doc/html/rfc7239)-style header (e.g. `for=1.2.3.4;by=proxy.example.com, for=5.6.7.8`). -* `proxy-trusted-addresses` is a comma-separated list of IP addresses that are removed from the forwarded header +* `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to the forwarded header (default: empty). @@ -613,7 +623,7 @@ Relevant flags to consider: # the visitor IP will be 9.9.9.9 (right-most unknown address). # behind-proxy: true - proxy-trusted-addresses: "1.2.3.4, 1.2.3.5" + proxy-trusted-hosts: "1.2.3.0/24, 1.2.2.2, 2001:db8::/64" ``` ### TLS/SSL @@ -1138,6 +1148,18 @@ If this ever happens, there will be a log message that looks something like this WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor ``` +### IPv6 considerations +By default, rate limiting for IPv6 is done using the `/64` subnet of the visitor's IPv6 address. This means that all visitors +in the same `/64` subnet are treated as one visitor. This is done to prevent abuse, as IPv6 subnet assignments are typically +much larger than IPv4 subnets (and much cheaper), and it is common for ISPs to assign large subnets to their customers. + +Other than that, rate limiting for IPv6 is done the same way as for IPv4, using the visitor's IP address or subnet to identify them. + +There are two options to configure the number of bits used for rate limiting (for IPv4 and IPv6): + +- `visitor-prefix-bits-ipv4` is number of bits of the IPv4 address to use for rate limiting (default: 32, full address) +- `visitor-prefix-bits-ipv6` is number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) + ### Subscriber-based rate limiting By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits @@ -1444,7 +1466,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | | `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | -| `proxy-trusted-addresses` | `NTFY_PROXY_TRUSTED_ADDRESSES` | *comma-separated list of IPs* | - | Comma-separated list of trusted IP addresses to remove from forwarded header | +| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header | | `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | | `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | | `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | @@ -1474,9 +1496,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | | `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | +| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | +| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | | `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | @@ -1572,6 +1596,7 @@ OPTIONS: --message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT] --global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] --visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] + --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING] --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] --visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] @@ -1580,8 +1605,11 @@ OPTIONS: --visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT] --visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] --visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] - --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING] - --behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] + --visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4] + --visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6] + --behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] + --proxy-forwarded-header value, --proxy_forwarded_header value use specified header to determine visitor IP address (for rate limiting) (default: "X-Forwarded-For") [$NTFY_PROXY_FORWARDED_HEADER] + --proxy-trusted-hosts value, --proxy_trusted_hosts value comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header [$NTFY_PROXY_TRUSTED_HOSTS] --stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY] --stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY] --billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT] @@ -1595,5 +1623,5 @@ OPTIONS: --web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES] --web-push-expiry-duration value, --web_push_expiry_duration value automatically expire unused subscriptions after this time (default: "60d") [$NTFY_WEB_PUSH_EXPIRY_DURATION] --web-push-expiry-warning-duration value, --web_push_expiry_warning_duration value send web push warning notification after this time before expiring unused subscriptions (default: "55d") [$NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION] - --help, -h show help + --help, -h ``` diff --git a/docs/releases.md b/docs/releases.md index 8bf1cc4e..dca68cc8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1437,7 +1437,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-addresses` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) +* Full support for IPv6 ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) +* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/go.mod b/go.mod index 6100a25f..7bddeb07 100644 --- a/go.mod +++ b/go.mod @@ -15,13 +15,13 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.10.0 - github.com/urfave/cli/v2 v2.27.6 - golang.org/x/crypto v0.38.0 + github.com/urfave/cli/v2 v2.27.7 + golang.org/x/crypto v0.39.0 golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.14.0 + golang.org/x/sync v0.15.0 golang.org/x/term v0.32.0 - golang.org/x/time v0.11.0 - google.golang.org/api v0.235.0 + golang.org/x/time v0.12.0 + google.golang.org/api v0.240.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -30,7 +30,7 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi require github.com/pkg/errors v0.9.1 // indirect require ( - firebase.google.com/go/v4 v4.15.2 + firebase.google.com/go/v4 v4.16.1 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.22.0 @@ -39,17 +39,17 @@ require ( require ( cel.dev/expr v0.24.0 // indirect - cloud.google.com/go v0.121.2 // indirect - cloud.google.com/go/auth v0.16.1 // indirect + cloud.google.com/go v0.121.3 // indirect + cloud.google.com/go/auth v0.16.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/monitoring v1.24.2 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -60,7 +60,7 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -75,30 +75,30 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.64.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.65.0 // indirect + github.com/prometheus/procfs v0.17.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/sdk v1.36.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/net v0.40.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/text v0.26.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect - google.golang.org/grpc v1.72.2 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d73473c5..18815b70 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.121.2 h1:v2qQpN6Dx9x2NmwrqlesOt3Ys4ol5/lFZ6Mg1B7OJCg= -cloud.google.com/go v0.121.2/go.mod h1:nRFlrHq39MNVWu+zESP2PosMWA0ryJw8KUBZ2iZpxbw= -cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= -cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= +cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= +cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= +cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= @@ -22,20 +22,20 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -firebase.google.com/go/v4 v4.15.2 h1:KJtV4rAfO2CVCp40hBfVk+mqUqg7+jQKx7yOgFDnXBg= -firebase.google.com/go/v4 v4.15.2/go.mod h1:qkD/HtSumrPMTLs0ahQrje5gTw2WKFKrzVFoqy4SbKA= +firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4= +firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0 h1:VaFXBL0NJpiFBtw4aVJpKHeKULVTcHpD+/G0ibZkcBw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.28.0/go.mod h1:JXkPazkEc/dZTHzOlzv2vT1DlpWSTbSLmu/1KY6Ly0I= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0 h1:QFgWzcdmJlgEAwJz/zePYVJQxfoJGRtgIqZfIUFg5oQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.52.0/go.mod h1:ayYHuYU7iNcNtEs1K9k6D/Bju7u1VEHMQm5qQ1n3GtM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0 h1:0l8ynskVvq1dvIn5vJbFMf/a/3TqFpRmCMrruFbzlvk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.52.0/go.mod h1:f/ad5NuHnYz8AOZGuR0cY+l36oSCstdxD73YlIchr6I= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0 h1:wbMd4eG/fOhsCa6+IP8uEDvWF5vl7rNoUWmP5f72Tbs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.52.0/go.mod h1:gdIm9TxRk5soClCwuB0FtdXsbqtw0aqPwBEurK9tPkw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= @@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= -github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -131,10 +131,10 @@ github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/ github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= -github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -149,41 +149,43 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= -github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= -github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0 h1:F7q2tNlCaHY9nMKHR6XH9/qkp8FktLnIcy6jJNyOCQw= -go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA= +go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -198,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -209,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -247,10 +249,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -259,18 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo= -google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg= +google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= +google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20250528174236-200df99c418a h1:KXuwdBmgjb4T3l4ZzXhP6HxxFKXD9FcK5/8qfJI4WwU= -google.golang.org/genproto v0.0.0-20250528174236-200df99c418a/go.mod h1:Nlk93rrS2X7rV8hiC2gh2A/AJspZhElz9Oh2KGsjLEY= -google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= -google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= diff --git a/server/config.go b/server/config.go index 0fc39932..59b11c16 100644 --- a/server/config.go +++ b/server/config.go @@ -135,7 +135,7 @@ type Config struct { VisitorAttachmentDailyBandwidthLimit int64 VisitorRequestLimitBurst int VisitorRequestLimitReplenish time.Duration - VisitorRequestExemptIPAddrs []netip.Prefix + VisitorRequestExemptPrefixes []netip.Prefix VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration @@ -143,13 +143,13 @@ type Config struct { VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int VisitorAuthFailureLimitReplenish time.Duration - VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats - VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics - VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32) - VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64) - BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) - ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) - ProxyTrustedAddresses []string // List of trusted proxy addresses (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true + VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats + VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics + VisitorPrefixBitsIPv4 int // Number of bits for IPv4 rate limiting (default: 32) + VisitorPrefixBitsIPv6 int // Number of bits for IPv6 rate limiting (default: 64) + BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) + ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) + ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true StripeSecretKey string StripeWebhookKey string StripePriceCacheDuration time.Duration @@ -159,7 +159,6 @@ type Config struct { EnableReservations bool // Allow users with role "user" to own/reserve topics EnableMetrics bool AccessControlAllowOrigin string // CORS header field to restrict access from web clients - Version string // injected by App WebPushPrivateKey string WebPushPublicKey string WebPushFile string @@ -167,6 +166,7 @@ type Config struct { WebPushStartupQueries string WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration + Version string // injected by App } // NewConfig instantiates a default new server config @@ -229,7 +229,7 @@ func NewConfig() *Config { VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0), + VisitorRequestExemptPrefixes: make([]netip.Prefix, 0), VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, diff --git a/server/server.go b/server/server.go index 8d33d396..bfa7eb6b 100644 --- a/server/server.go +++ b/server/server.go @@ -760,7 +760,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e // the subscription as invalid if any 400-499 code (except 429/408) is returned. // See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46 return nil, errHTTPInsufficientStorageUnifiedPush.With(t) - } else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() { + } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return nil, errHTTPTooManyRequestsLimitMessages.With(t) } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) @@ -1937,7 +1937,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun // that subsequent logging calls still have a visitor context. func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { // Read the "Authorization" header value and exit out early if it's not set - ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses) + ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) vip := s.visitor(ip, nil) if s.userManager == nil { return vip, nil @@ -2012,7 +2012,7 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us if err != nil { return nil, err } - ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedAddresses) + ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{ LastAccess: time.Now(), LastOrigin: ip, diff --git a/server/server.yml b/server/server.yml index 37e0faf5..e1a58232 100644 --- a/server/server.yml +++ b/server/server.yml @@ -105,13 +105,13 @@ # proxy-forwarded-header. Without this, the remote address of the incoming connection is used. # - proxy-forwarded-header is the header to use to identify visitors. It may be a single IP address (e.g. 1.2.3.4), # a comma-separated list of IP addresses (e.g. "1.2.3.4, 5.6.7.8"), or an RFC 7239-style header (e.g. "for=1.2.3.4;by=proxy.example.com, for=5.6.7.8"). -# - proxy-trusted-addresses is a comma-separated list of IP addresses that are removed from the forwarded header +# - proxy-trusted-hosts is a comma-separated list of IP addresses, hostnames or CIDRs that are removed from the forwarded header # to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to # the forwarded header. # # behind-proxy: false # proxy-forwarded-header: "X-Forwarded-For" -# proxy-trusted-addresses: +# proxy-trusted-hosts: # If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments # are "attachment-cache-dir" and "base-url". diff --git a/server/server_middleware.go b/server/server_middleware.go index b2ce6f70..17ae0963 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -16,7 +16,7 @@ const ( func (s *Server) limitRequests(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) { + if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { return next(w, r, v) } else if !v.RequestAllowed() { return errHTTPTooManyRequestsLimitRequests @@ -40,7 +40,7 @@ func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { contextRateVisitor: vrate, contextTopic: t, }) - if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) { + if util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) { return next(w, r, v) } else if !vrate.RequestAllowed() { return errHTTPTooManyRequestsLimitRequests diff --git a/server/server_test.go b/server/server_test.go index 0a5bcc08..e09f67a2 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1225,7 +1225,7 @@ func TestServer_PublishTooManyRequests_IPv6_Slash48(t *testing.T) { func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { c := newTestConfig(t) c.VisitorRequestLimitBurst = 3 - c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() s := newTestServer(t, c) for i := 0; i < 5; i++ { // > 3 response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil) @@ -1236,7 +1236,7 @@ func TestServer_PublishTooManyRequests_Defaults_ExemptHosts(t *testing.T) { func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_IPv6(t *testing.T) { c := newTestConfig(t) c.VisitorRequestLimitBurst = 3 - c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:9999::/48")} s := newTestServer(t, c) overrideRemoteAddr := func(r *http.Request) { r.RemoteAddr = "[2001:db8:9999::1]:1234" @@ -1251,7 +1251,7 @@ func TestServer_PublishTooManyRequests_Defaults_ExemptHosts_MessageDailyLimit(t c := newTestConfig(t) c.VisitorRequestLimitBurst = 10 c.VisitorMessageDailyLimit = 4 - c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() + c.VisitorRequestExemptPrefixes = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request() s := newTestServer(t, c) for i := 0; i < 8; i++ { // 4 response := request(t, s, "PUT", "/mytopic", "message", nil) @@ -2318,7 +2318,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) { c := newTestConfig(t) c.BehindProxy = true c.ProxyForwardedHeader = "Forwarded" - c.ProxyTrustedAddresses = []string{"1.2.3.4"} + c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")} s := newTestServer(t, c) r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "8.9.10.11:1234" @@ -2332,7 +2332,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) { c := newTestConfig(t) c.BehindProxy = true c.ProxyForwardedHeader = "Forwarded" - c.ProxyTrustedAddresses = []string{"2001:db8:1111::1"} + c.ProxyTrustedPrefixes = []netip.Prefix{netip.MustParsePrefix("2001:db8:1111::/64")} s := newTestServer(t, c) r, _ := http.NewRequest("GET", "/bla", nil) r.RemoteAddr = "[2001:db8:2222::1]:1234" diff --git a/server/util.go b/server/util.go index 687e7d0e..305f63ea 100644 --- a/server/util.go +++ b/server/util.go @@ -9,7 +9,6 @@ import ( "net/http" "net/netip" "regexp" - "slices" "strings" "heckel.io/ntfy/v2/util" @@ -84,9 +83,9 @@ func readQueryParam(r *http.Request, names ...string) string { // extractIPAddress extracts the IP address of the visitor from the request, // either from the TCP socket or from a proxy header. -func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedAddresses []string) netip.Addr { +func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader string, proxyTrustedPrefixes []netip.Prefix) netip.Addr { if behindProxy && proxyForwardedHeader != "" { - if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedAddresses); err == nil { + if addr, err := extractIPAddressFromHeader(r, proxyForwardedHeader, proxyTrustedPrefixes); err == nil { return addr } // Fall back to the remote address if the header is not found or invalid @@ -109,7 +108,7 @@ func extractIPAddress(r *http.Request, behindProxy bool, proxyForwardedHeader st // If there are multiple addresses, we first remove the trusted IP addresses from the list, and // then take the right-most address in the list (as this is the one added by our proxy server). // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details. -func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedAddresses []string) (netip.Addr, error) { +func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trustedPrefixes []netip.Prefix) (netip.Addr, error) { value := strings.TrimSpace(strings.ToLower(r.Header.Get(forwardedHeader))) if value == "" { return netip.IPv4Unspecified(), fmt.Errorf("no %s header found", forwardedHeader) @@ -133,7 +132,12 @@ func extractIPAddressFromHeader(r *http.Request, forwardedHeader string, trusted } // Filter out proxy addresses clientAddrs := util.Filter(validAddrs, func(addr netip.Addr) bool { - return !slices.Contains(trustedAddresses, addr.String()) + for _, prefix := range trustedPrefixes { + if prefix.Contains(addr) { + return false // Address is in the trusted range, ignore it + } + } + return true }) if len(clientAddrs) == 0 { return netip.IPv4Unspecified(), fmt.Errorf("no client IP address found in %s header: %s", forwardedHeader, value) diff --git a/server/util_test.go b/server/util_test.go index 128a8160..13dcb1e9 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -100,7 +100,7 @@ func TestExtractIPAddress(t *testing.T) { r.Header.Set("X-Real-IP", "13.14.15.16, 1.1.1.1") r.Header.Set("Forwarded", "for=17.18.19.20;by=proxy.example.com, by=2.2.2.2;for=1.1.1.1") - trustedProxies := []string{"1.1.1.1"} + trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")} require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) require.Equal(t, "9.10.11.12", extractIPAddress(r, true, "X-Client-IP", trustedProxies).String()) @@ -115,7 +115,7 @@ func TestExtractIPAddress_UnixSocket(t *testing.T) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8, 1.1.1.1") r.Header.Set("Forwarded", "by=bla.example.com;for=17.18.19.20") - trustedProxies := []string{"1.1.1.1"} + trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.1.1.1/32")} require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) require.Equal(t, "17.18.19.20", extractIPAddress(r, true, "Forwarded", trustedProxies).String()) @@ -126,26 +126,18 @@ func TestExtractIPAddress_MixedIPv4IPv6(t *testing.T) { r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) r.RemoteAddr = "[2001:db8:abcd::1]:1234" r.Header.Set("X-Forwarded-For", "1.2.3.4, 2001:db8:abcd::2, 5.6.7.8") - trustedProxies := []string{"1.2.3.4"} + trustedProxies := []netip.Prefix{netip.MustParsePrefix("1.2.3.0/24")} require.Equal(t, "5.6.7.8", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) } func TestExtractIPAddress_TrustedIPv6Prefix(t *testing.T) { r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) r.RemoteAddr = "[2001:db8:abcd::1]:1234" - r.Header.Set("X-Forwarded-For", "2001:db8:abcd::1, 2001:db8:abcd:1::2, 2001:db8:abcd:2::3") - trustedProxies := []string{"2001:db8:abcd::/48"} + r.Header.Set("X-Forwarded-For", "2001:db8:aaaa::1, 2001:db8:aaaa::2, 2001:db8:abcd:2::3") + trustedProxies := []netip.Prefix{netip.MustParsePrefix("2001:db8:aaaa::/48")} require.Equal(t, "2001:db8:abcd:2::3", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) } -func TestExtractIPAddress_EdgeCases(t *testing.T) { - r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", nil) - r.RemoteAddr = "[::ffff:192.0.2.128]:1234" // IPv4-mapped IPv6 - r.Header.Set("X-Forwarded-For", "::ffff:192.0.2.128, 2001:db8:abcd::1") - trustedProxies := []string{"::ffff:192.0.2.128"} - require.Equal(t, "2001:db8:abcd::1", extractIPAddress(r, true, "X-Forwarded-For", trustedProxies).String()) -} - func TestVisitorID(t *testing.T) { confWithDefaults := &Config{ VisitorPrefixBitsIPv4: 32, diff --git a/web/package-lock.json b/web/package-lock.json index 34e91d7c..1597e504 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -74,9 +74,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", - "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "dev": true, "license": "MIT", "engines": { @@ -84,22 +84,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", + "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -122,15 +122,15 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -208,22 +208,31 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -386,26 +395,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.4.tgz", - "integrity": "sha512-Y+bO6U+I7ZKaM5G5rDUZiYfUvQPUibYmAFe7EnKdnKBbVXDZxvp+MWOH5gYciY0EPk4EScsuFMQBbEfpdRKSCQ==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3" + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.4.tgz", - "integrity": "sha512-BRmLHGwpUqLFR2jzx9orBuX/ABDkj2jLKOXrHDTN2aOKL+jFDDKaRNo9nyYsIl9h/UE/7lMKdDjKQQyxKKDZ7g==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -577,15 +586,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", - "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -629,9 +638,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz", - "integrity": "sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", + "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -679,18 +688,18 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", - "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", + "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "globals": "^11.1.0" + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -717,13 +726,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.3.tgz", - "integrity": "sha512-s4Jrok82JpiaIprtY2nHsYmrThKvvwgHwjgd7UMiYhZaN0asdXNLr0y+NjTfkA7SyQE5i2Fb7eawUOZmLvyqOA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -798,6 +808,23 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-exponentiation-operator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", @@ -1065,16 +1092,17 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.3.tgz", - "integrity": "sha512-7ZZtznF9g4l2JCImCo5LNKFHB5eXnN39lLtLY5Tg+VkR0jwOt7TBciMckuiQIOIW7L5tkQOCh3bVGYeXgMx52Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", + "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.3", - "@babel/plugin-transform-parameters": "^7.27.1" + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1134,9 +1162,9 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", - "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "dev": true, "license": "MIT", "dependencies": { @@ -1233,9 +1261,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.4.tgz", - "integrity": "sha512-Glp/0n8xuj+E1588otw5rjJkTXfzW7FjH3IIUrfqiZOPQCd2vbg8e+DQE8jK9g4V5/zrxFW+D9WM9gboRPELpQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", + "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,13 +1458,13 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz", - "integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", + "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", @@ -1450,19 +1478,20 @@ "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", "@babel/plugin-transform-async-to-generator": "^7.27.1", "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", "@babel/plugin-transform-class-properties": "^7.27.1", "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.0", "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", "@babel/plugin-transform-dotall-regex": "^7.27.1", "@babel/plugin-transform-duplicate-keys": "^7.27.1", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", "@babel/plugin-transform-exponentiation-operator": "^7.27.1", "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-for-of": "^7.27.1", @@ -1479,15 +1508,15 @@ "@babel/plugin-transform-new-target": "^7.27.1", "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.27.2", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", "@babel/plugin-transform-object-super": "^7.27.1", "@babel/plugin-transform-optional-catch-binding": "^7.27.1", "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/plugin-transform-private-property-in-object": "^7.27.1", "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.0", "@babel/plugin-transform-regexp-modifiers": "^7.27.1", "@babel/plugin-transform-reserved-words": "^7.27.1", "@babel/plugin-transform-shorthand-properties": "^7.27.1", @@ -1500,10 +1529,10 @@ "@babel/plugin-transform-unicode-regex": "^7.27.1", "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "engines": { @@ -1529,9 +1558,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz", - "integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1552,27 +1581,27 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1691,9 +1720,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -2218,35 +2247,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -2296,17 +2296,13 @@ "license": "BSD-3-Clause" }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2318,19 +2314,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2339,15 +2326,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2648,9 +2635,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", "dev": true, "license": "MIT" }, @@ -2703,9 +2690,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -2726,9 +2713,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz", - "integrity": "sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", "cpu": [ "arm" ], @@ -2740,9 +2727,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz", - "integrity": "sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", "cpu": [ "arm64" ], @@ -2754,9 +2741,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", - "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", "cpu": [ "arm64" ], @@ -2768,9 +2755,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz", - "integrity": "sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", "cpu": [ "x64" ], @@ -2782,9 +2769,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz", - "integrity": "sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", "cpu": [ "arm64" ], @@ -2796,9 +2783,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz", - "integrity": "sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", "cpu": [ "x64" ], @@ -2810,9 +2797,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz", - "integrity": "sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", "cpu": [ "arm" ], @@ -2824,9 +2811,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz", - "integrity": "sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", "cpu": [ "arm" ], @@ -2838,9 +2825,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz", - "integrity": "sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", "cpu": [ "arm64" ], @@ -2852,9 +2839,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz", - "integrity": "sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", "cpu": [ "arm64" ], @@ -2866,9 +2853,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz", - "integrity": "sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", "cpu": [ "loong64" ], @@ -2880,9 +2867,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz", - "integrity": "sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", "cpu": [ "ppc64" ], @@ -2894,9 +2881,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz", - "integrity": "sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", "cpu": [ "riscv64" ], @@ -2908,9 +2895,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz", - "integrity": "sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", "cpu": [ "riscv64" ], @@ -2922,9 +2909,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz", - "integrity": "sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", "cpu": [ "s390x" ], @@ -2936,9 +2923,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz", - "integrity": "sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", "cpu": [ "x64" ], @@ -2950,9 +2937,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz", - "integrity": "sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", "cpu": [ "x64" ], @@ -2964,9 +2951,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz", - "integrity": "sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", "cpu": [ "arm64" ], @@ -2978,9 +2965,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz", - "integrity": "sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", "cpu": [ "ia32" ], @@ -2992,9 +2979,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz", - "integrity": "sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", "cpu": [ "x64" ], @@ -3071,9 +3058,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -3100,15 +3087,15 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", - "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "license": "MIT", "peer": true, "dependencies": { @@ -3152,16 +3139,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", - "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -3169,13 +3156,13 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -3273,18 +3260,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3499,14 +3488,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { @@ -3514,27 +3503,27 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.4" + "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -3558,9 +3547,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3569,9 +3558,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "dev": true, "funding": [ { @@ -3589,8 +3578,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -3668,9 +3657,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001720", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz", - "integrity": "sha512-Ec/2yV2nNPwb4DnTANEV99ZWwm3ZWfdlfkQbWSDDt+PsXEVYwlhPH8tdMaPunYTKKmz7AnHi2oNEi1GcmKCD8g==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "dev": true, "funding": [ { @@ -3812,13 +3801,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.42.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", - "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", + "integrity": "sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.24.4" + "browserslist": "^4.25.0" }, "funding": { "type": "opencollective", @@ -4105,9 +4094,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.161", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.161.tgz", - "integrity": "sha512-hwtetwfKNZo/UlwHIVBlKZVdy7o8bIZxxKs0Mv/ROPiQQQmDgdm5a+KvKtBsxM8ZjFzTaCeLoodZ8jiBE3o9rA==", + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", "dev": true, "license": "ISC" }, @@ -4511,9 +4500,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", - "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -4539,30 +4528,30 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", - "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -4732,35 +4721,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -4887,9 +4847,9 @@ } }, "node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4925,9 +4885,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5196,12 +5156,19 @@ } }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globalthis": { @@ -5385,9 +5352,9 @@ } }, "node_modules/humanize-duration": { - "version": "3.32.2", - "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.32.2.tgz", - "integrity": "sha512-jcTwWYeCJf4dN5GJnjBmHd42bNyK94lY49QTkrsAQrMTUoIYLevvDpmQtg5uv8ZrdIRIbzdasmSNZ278HHUPEg==", + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.0.tgz", + "integrity": "sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==", "license": "Unlicense" }, "node_modules/i18next": { @@ -6895,9 +6862,9 @@ } }, "node_modules/postcss": { - "version": "8.5.4", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", - "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -7397,13 +7364,13 @@ } }, "node_modules/rollup": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", - "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -7413,26 +7380,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.41.1", - "@rollup/rollup-android-arm64": "4.41.1", - "@rollup/rollup-darwin-arm64": "4.41.1", - "@rollup/rollup-darwin-x64": "4.41.1", - "@rollup/rollup-freebsd-arm64": "4.41.1", - "@rollup/rollup-freebsd-x64": "4.41.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", - "@rollup/rollup-linux-arm-musleabihf": "4.41.1", - "@rollup/rollup-linux-arm64-gnu": "4.41.1", - "@rollup/rollup-linux-arm64-musl": "4.41.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-musl": "4.41.1", - "@rollup/rollup-linux-s390x-gnu": "4.41.1", - "@rollup/rollup-linux-x64-gnu": "4.41.1", - "@rollup/rollup-linux-x64-musl": "4.41.1", - "@rollup/rollup-win32-arm64-msvc": "4.41.1", - "@rollup/rollup-win32-ia32-msvc": "4.41.1", - "@rollup/rollup-win32-x64-msvc": "4.41.1", + "@rollup/rollup-android-arm-eabi": "4.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", "fsevents": "~2.3.2" } }, @@ -8089,10 +8056,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { - "version": "5.40.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.40.0.tgz", - "integrity": "sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA==", + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8197,9 +8177,9 @@ } }, "node_modules/type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -8626,9 +8606,9 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.0.0.tgz", - "integrity": "sha512-X77jo0AOd5OcxmWj3WnVti8n7Kw2tBgV1c8MCXFclrSlDV23ePzv2eTDIALXI2Qo6nJ5pZJeZAuX0AawvRfoeA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.0.1.tgz", + "integrity": "sha512-STyUomQbydj7vGamtgQYIJI0YsUZ3T4pJLGBQDQPhzMse6aGSncmEN21OV35PrFsmCvmtiH+Nu1JS1ke4RqBjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8646,7 +8626,7 @@ }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "workbox-build": "^7.3.0", "workbox-window": "^7.3.0" }, From 8f60294c5b4cc8f0d0720474a869a6014d86b2b3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 5 Jul 2025 22:48:45 +0200 Subject: [PATCH 22/87] Docs --- docs/config.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/config.md b/docs/config.md index 6675b875..0e677f75 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1324,6 +1324,22 @@ Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the j is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD` chain. +## IPv6 support +ntfy fully supports IPv6, though there are a few things to keep in mind. + +- **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to + explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. Alternatively, + if you're running ntfy behind a reverse proxy, make sure that the proxy is configured to listen on an IPv6 address (e.g. `listen [::]:80;` in nginx). +- **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64` + subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different + value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details. +- **Banning IPs with fail2ban:** If you use fail2ban to ban IPs, please ensure that your `actionban` and `actionunban` commands + support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details. + +!!! info + The official ntfy.sh server supports IPv6. Check out ntfy.sh's [Ansible repository](https://github.com/binwiederhier/ntfy-ansible) for examples of how to + configure [ntfy](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/ntfy), [nginx](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/nginx) and [fail2ban](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban). + ## Health checks A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below. If a non-200 HTTP status code is returned or if the returned `healthy` field is `false` the ntfy service should be considered as unhealthy. From 6fbcd85d17568fed75d847c563f75d04bdaf51ca Mon Sep 17 00:00:00 2001 From: srevn Date: Sun, 6 Jul 2025 10:23:32 +0300 Subject: [PATCH 23/87] Add piping support --- cmd/publish.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmd/publish.go b/cmd/publish.go index aaec35e9..89475fbd 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -254,6 +254,16 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com if c.String("message") != "" { message = c.String("message") } + + // If no message provided and stdin has data, read from stdin + if message == "" && stdinHasData() { + var stdinBytes []byte + stdinBytes, err = io.ReadAll(c.App.Reader) + if err != nil { + return + } + message = strings.TrimSpace(string(stdinBytes)) + } return } @@ -312,3 +322,11 @@ func runAndWaitForCommand(command []string) (message string, err error) { log.Debug("Command succeeded after %s: %s", runtime, prettyCmd) return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil } + +func stdinHasData() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) == 0 +} From 04aff72631b1b23a7a327cee097023211bed009e Mon Sep 17 00:00:00 2001 From: srevn Date: Sun, 6 Jul 2025 10:51:28 +0300 Subject: [PATCH 24/87] Add example and logging --- cmd/publish.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/publish.go b/cmd/publish.go index 89475fbd..1d04b537 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -69,6 +69,7 @@ Examples: ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment + echo 'message' | ntfy publish mytopic # Send message from stdin ntfy pub -u phil:mypass secret Psst # Publish with username/password ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes @@ -260,6 +261,7 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com var stdinBytes []byte stdinBytes, err = io.ReadAll(c.App.Reader) if err != nil { + log.Debug("Failed to read from stdin: %v", err) return } message = strings.TrimSpace(string(stdinBytes)) @@ -326,6 +328,7 @@ func runAndWaitForCommand(command []string) (message string, err error) { func stdinHasData() bool { stat, err := os.Stdin.Stat() if err != nil { + log.Debug("Failed to stat stdin: %v", err) return false } return (stat.Mode() & os.ModeCharDevice) == 0 From 9ed96e5d8b16944d40a6e2e6a2ddf924112a2fc5 Mon Sep 17 00:00:00 2001 From: srevn Date: Sun, 6 Jul 2025 16:31:03 +0300 Subject: [PATCH 25/87] Small cosmetic fixes --- cmd/publish.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/cmd/publish.go b/cmd/publish.go index 1d04b537..d1ccf79a 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -255,16 +255,14 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com if c.String("message") != "" { message = c.String("message") } - - // If no message provided and stdin has data, read from stdin - if message == "" && stdinHasData() { - var stdinBytes []byte - stdinBytes, err = io.ReadAll(c.App.Reader) + if message == "" && isStdinRedirected() { + var bytes []byte + bytes, err = io.ReadAll(c.App.Reader) if err != nil { - log.Debug("Failed to read from stdin: %v", err) + log.Debug("Failed to read from stdin: %s", err.Error()) return } - message = strings.TrimSpace(string(stdinBytes)) + message = strings.TrimSpace(string(bytes)) } return } @@ -325,10 +323,10 @@ func runAndWaitForCommand(command []string) (message string, err error) { return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil } -func stdinHasData() bool { +func isStdinRedirected() bool { stat, err := os.Stdin.Stat() if err != nil { - log.Debug("Failed to stat stdin: %v", err) + log.Debug("Failed to stat stdin: %s", err.Error()) return false } return (stat.Mode() & os.ModeCharDevice) == 0 From 47da3aeea6ab36cbe7f5990282bb1028334ba8c6 Mon Sep 17 00:00:00 2001 From: srevn Date: Sun, 6 Jul 2025 17:53:04 +0300 Subject: [PATCH 26/87] fix unbounded read --- cmd/publish.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/publish.go b/cmd/publish.go index d1ccf79a..c15761ab 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -256,13 +256,13 @@ func parseTopicMessageCommand(c *cli.Context) (topic string, message string, com message = c.String("message") } if message == "" && isStdinRedirected() { - var bytes []byte - bytes, err = io.ReadAll(c.App.Reader) + var data []byte + data, err = io.ReadAll(io.LimitReader(c.App.Reader, 1024*1024)) if err != nil { log.Debug("Failed to read from stdin: %s", err.Error()) return } - message = strings.TrimSpace(string(bytes)) + message = strings.TrimSpace(string(data)) } return } From 4578835a8f049dc7e7d859b9a1dc2c426f78aeb5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 11:04:33 +0200 Subject: [PATCH 27/87] stdin --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index dca68cc8..266f68e6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1439,6 +1439,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Full support for IPv6 ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) * Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) +* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) ### ntfy Android app v1.16.1 (UNRELEASED) From 19a4e95a3a897326e108f6d9306c8beaf6b18987 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 16:49:15 +0200 Subject: [PATCH 28/87] Docs --- docs/config.md | 41 ++++++++++++++++++++++++++++------------- docs/releases.md | 2 +- server/util_test.go | 4 ++++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/config.md b/docs/config.md index cbf34847..be15c9fc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -559,16 +559,6 @@ as the primary identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address. -In IPv4 environments, by default, a visitor's IP address is used as-is for rate limiting (**full IPv4 address**). This -means that if a visitor publishes messages from multiple IP addresses, they will be counted as separate visitors. -You can adjust this by setting the `visitor-prefix-bits-ipv4` config option (default is `32`, which is the entire IP address). -To limit visitors to a /24 subnet, for instance, set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor. - -In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that -`2001:db8::1` and `2001:db8::2` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to -adjust this behavior (default is `64`, which is the entire /64 subnet). See [IPv6 considerations](#ipv6-considerations) -for more details. - Relevant flags to consider: * `behind-proxy` makes it so that the real visitor IP address is extracted from the header defined in `proxy-forwarded-header`. @@ -579,6 +569,14 @@ Relevant flags to consider: * `proxy-trusted-hosts` is a comma-separated list of IP addresses, hosts or CIDRs that are removed from the forwarded header to determine the real IP address. This is only useful if there are multiple proxies involved that add themselves to the forwarded header (default: empty). +* `visitor-prefix-bits-ipv4` is the number of bits of the IPv4 address to use for rate limiting (default is `32`, which is the entire + IP address). In IPv4 environments, by default, a visitor's **full IPv4 address** is used as-is for rate limiting. This means that + if someone publishes messages from multiple IP addresses, they will be counted as separate visitors. You can adjust this by setting the `visitor-prefix-bits-ipv4` config option. To group visitors in a /24 subnet and count them as one, for instance, + set it to `24`. In that case, `1.2.3.4` and `1.2.3.99` are treated as the same visitor. +* `visitor-prefix-bits-ipv6` is the number of bits of the IPv6 address to use for rate limiting (default is `64`, which is a /64 subnet). + In IPv6 environments, by default, a visitor's IP address is **truncated to the /64 subnet**, meaning that `2001:db8:25:86:1::1` and + `2001:db8:25:86:2::1` are treated as the same visitor. Use the `visitor-prefix-bits-ipv6` config option to adjust this behavior. + See [IPv6 considerations](#ipv6-considerations) for more details. === "/etc/ntfy/server.yml (behind a proxy)" ``` yaml @@ -624,6 +622,20 @@ Relevant flags to consider: proxy-trusted-hosts: "1.2.3.0/24, 1.2.2.2, 2001:db8::/64" ``` +=== "/etc/ntfy/server.yml (adjusted IPv4/IPv6 prefixes proxies)" + ``` yaml + # Tell ntfy to treat visitors as being in a /24 subnet (IPv4) or /48 subnet (IPv6) + # as one visitor, so that they are counted as one for rate limiting. + # + # Example 1: If 1.2.3.4 and 1.2.3.5 publish a message, the visitor 1.2.3.0 will have + # used 2 messages. + # Example 2: If 2001:db8:2500:1::1 and 2001:db8:2500:2::1 publish a message, the visitor + # 2001:db8:2500:: will have used 2 messages. + # + visitor-prefix-bits-ipv4: 24 + visitor-prefix-bits-ipv6: 48 + ``` + ### TLS/SSL ntfy supports HTTPS/TLS by setting the `listen-https` [config option](#config-options). However, if you are behind a proxy, it is recommended that TLS/SSL termination is done by the proxy itself (see below). @@ -1322,16 +1334,19 @@ Note that if you run nginx in a container, append `, chain=DOCKER-USER` to the j is `INPUT`, but `FORWARD` is used when using docker networks. `DOCKER-USER`, available when using docker, is part of the `FORWARD` chain. +The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to +4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix. + ## IPv6 support ntfy fully supports IPv6, though there are a few things to keep in mind. - **Listening on an IPv6 address**: By default, ntfy listens on `:80` (IPv4-only). If you want to listen on an IPv6 address, you need to - explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. Alternatively, - if you're running ntfy behind a reverse proxy, make sure that the proxy is configured to listen on an IPv6 address (e.g. `listen [::]:80;` in nginx). + explicitly set the `listen-http` and/or `listen-https` options in your `server.yml` file to an IPv6 address, e.g. `[::]:80`. To listen on + IPv4 and IPv6, you must run ntfy behind a reverse proxy, e.g. `listen :80; listen [::]:80;` in nginx. - **Rate limiting:** By default, ntfy uses the `/64` subnet of the visitor's IPv6 address for rate limiting. This means that all visitors in the same `/64` subnet are treated as one visitor. If you want to change this, you can set the `visitor-prefix-bits-ipv6` option in your `server.yml` file to a different value (e.g. `48` for `/48` subnets). See [IPv6 considerations](#ipv6-considerations) and [IP-based rate limiting](#ip-based-rate-limiting) for more details. -- **Banning IPs with fail2ban:** If you use fail2ban to ban IPs, please ensure that your `actionban` and `actionunban` commands +- **Banning IPs with fail2ban:** By default, if you're using the `iptables-multiport` action, fail2ban bans individual IPv4 and IPv6 addresses via `iptables` and `ip6tables`. While this behavior is fine for IPv4, it is not for IPv6, because every host can technically have up to 2^64 addresses. Please ensure that your `actionban` and `actionunban` commands support IPv6 and also ban the entire prefix (e.g. `/48`). See [Banning bad actors](#banning-bad-actors-fail2ban) for details. !!! info diff --git a/docs/releases.md b/docs/releases.md index 266f68e6..c3dc54e1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1437,7 +1437,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Full support for IPv6 ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) +* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) * Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) * Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) diff --git a/server/util_test.go b/server/util_test.go index 13dcb1e9..9530ca6a 100644 --- a/server/util_test.go +++ b/server/util_test.go @@ -153,8 +153,12 @@ func TestVisitorID(t *testing.T) { } require.Equal(t, "ip:1.2.3.4", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithDefaults)) require.Equal(t, "ip:2a01:599:b26:2397::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithDefaults)) + require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:1::1"), nil, confWithDefaults)) + require.Equal(t, "ip:2001:db8:25:86::", visitorID(netip.MustParseAddr("2001:db8:25:86:2::1"), nil, confWithDefaults)) + require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("1.2.3.4"), userWithTier, confWithDefaults)) require.Equal(t, "user:u_123", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), userWithTier, confWithDefaults)) + require.Equal(t, "ip:1.2.0.0", visitorID(netip.MustParseAddr("1.2.3.4"), nil, confWithShortenedPrefixes)) require.Equal(t, "ip:2a01:599:b26:2300::", visitorID(netip.MustParseAddr("2a01:599:b26:2397:dbe7:5aa2:95ce:1e83"), nil, confWithShortenedPrefixes)) } From de7b7218e41c2d5a35259ba6a84e5689d89b21e4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 18:28:16 +0200 Subject: [PATCH 29/87] Add languages --- go.sum | 1 + web/package-lock.json | 14 +++++++------- web/src/components/Preferences.jsx | 4 ++++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/go.sum b/go.sum index 18815b70..6224fb65 100644 --- a/go.sum +++ b/go.sum @@ -263,6 +263,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= diff --git a/web/package-lock.json b/web/package-lock.json index 1597e504..ea4962a4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3657,9 +3657,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", "dev": true, "funding": [ { @@ -3801,13 +3801,13 @@ "license": "MIT" }, "node_modules/core-js-compat": { - "version": "3.43.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", - "integrity": "sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==", + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", + "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", "dev": true, "license": "MIT", "dependencies": { - "browserslist": "^4.25.0" + "browserslist": "^4.25.1" }, "funding": { "type": "opencollective", diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index c733c23c..8621a263 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -576,8 +576,10 @@ const Language = () => { 简体中文 Dansk Deutsch + Eesti Español Français + Galego Italiano Magyar 한국어 @@ -589,6 +591,8 @@ const Language = () => { Português (Brasil) Polski Русский + Română + Slovenčina Suomi Svenska Türkçe From 1edbda4f318239865f5c0c742b4be1a229ab55af Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 18:34:05 +0200 Subject: [PATCH 30/87] Release notes --- docs/releases.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index c3dc54e1..0877527e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1441,6 +1441,11 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) * Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) +**Languages** + +* Update new languages from Weblate. Thanks to all the contributors! +* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app + ### ntfy Android app v1.16.1 (UNRELEASED) **Features:** From f5247c50f4276b064880e7542271a873feb1540a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 21:24:43 +0200 Subject: [PATCH 31/87] Bump --- go.sum | 1 - 1 file changed, 1 deletion(-) diff --git a/go.sum b/go.sum index 6224fb65..18815b70 100644 --- a/go.sum +++ b/go.sum @@ -263,7 +263,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= From efef5876717f82e9a2b449ff590dd65a7ae8c02c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 7 Jul 2025 22:36:01 +0200 Subject: [PATCH 32/87] WIP: Predefined users --- cmd/serve.go | 3 +++ cmd/user.go | 1 - server/config.go | 1 + server/server.go | 9 +++++++- user/manager.go | 55 +++++++++++++++++++++++++++++++----------------- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..516356c5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,6 +52,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -157,6 +158,7 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") + authUsers := c.StringSlice("auth-users") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -406,6 +408,7 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault + conf.AuthUsers = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index e6867b11..9902dace 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -94,7 +94,6 @@ Example: You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass directly the bcrypt hash. This is useful if you are updating users via scripts. - `, }, { diff --git a/server/config.go b/server/config.go index 59b11c16..67554021 100644 --- a/server/config.go +++ b/server/config.go @@ -93,6 +93,7 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission + AuthUsers []user.User AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index bfa7eb6b..10ad7d8e 100644 --- a/server/server.go +++ b/server/server.go @@ -189,7 +189,14 @@ func New(conf *Config) (*Server, error) { } var userManager *user.Manager if conf.AuthFile != "" { - userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval) + authConfig := &user.Config{ + Filename: conf.AuthFile, + StartupQueries: conf.AuthStartupQueries, + DefaultAccess: conf.AuthDefault, + BcryptCost: conf.AuthBcryptCost, + QueueWriterInterval: conf.AuthStatsQueueWriterInterval, + } + userManager, err = user.NewManager(authConfig) if err != nil { return nil, err } diff --git a/user/manager.go b/user/manager.go index 814ee827..04c3c878 100644 --- a/user/manager.go +++ b/user/manager.go @@ -441,36 +441,53 @@ var ( // Manager is an implementation of Manager. It stores users and access control list // in a SQLite database. type Manager struct { - db *sql.DB - defaultAccess Permission // Default permission if no ACL matches - statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) - tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) - bcryptCost int // Makes testing easier - mu sync.Mutex + config *Config + db *sql.DB + statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats) + tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate) + mu sync.Mutex +} + +type Config struct { + Filename string + StartupQueries string + DefaultAccess Permission // Default permission if no ACL matches + ProvisionedUsers []*User // Predefined users to create on startup + ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup + BcryptCost int // Makes testing easier + QueueWriterInterval time.Duration } var _ Auther = (*Manager)(nil) // NewManager creates a new Manager instance -func NewManager(filename, startupQueries string, defaultAccess Permission, bcryptCost int, queueWriterInterval time.Duration) (*Manager, error) { - db, err := sql.Open("sqlite3", filename) +func NewManager(config *Config) (*Manager, error) { + // Set defaults + if config.BcryptCost <= 0 { + config.BcryptCost = DefaultUserPasswordBcryptCost + } + if config.QueueWriterInterval.Seconds() <= 0 { + config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval + } + + // Open DB and run setup queries + db, err := sql.Open("sqlite3", config.Filename) if err != nil { return nil, err } if err := setupDB(db); err != nil { return nil, err } - if err := runStartupQueries(db, startupQueries); err != nil { + if err := runStartupQueries(db, config.StartupQueries); err != nil { return nil, err } manager := &Manager{ - db: db, - defaultAccess: defaultAccess, - statsQueue: make(map[string]*Stats), - tokenQueue: make(map[string]*TokenUpdate), - bcryptCost: bcryptCost, + db: db, + config: config, + statsQueue: make(map[string]*Stats), + tokenQueue: make(map[string]*TokenUpdate), } - go manager.asyncQueueWriter(queueWriterInterval) + go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -843,7 +860,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { } defer rows.Close() if !rows.Next() { - return a.resolvePerms(a.defaultAccess, perm) + return a.resolvePerms(a.config.DefaultAccess, perm) } var read, write bool if err := rows.Scan(&read, &write); err != nil { @@ -873,7 +890,7 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1205,7 +1222,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { if hashed { hash = []byte(password) } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost) + hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) if err != nil { return err } @@ -1387,7 +1404,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error { // DefaultAccess returns the default read/write access if no access control entry matches func (a *Manager) DefaultAccess() Permission { - return a.defaultAccess + return a.config.DefaultAccess } // AddTier creates a new tier in the database From 1f2c76e63d3c256f335918f9653527ed5526c6a6 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 7 Jul 2025 22:23:32 -0600 Subject: [PATCH 33/87] copy subset of Sprig template functions --- README.md | 1 + docs/publish.md | 5 +- docs/releases.md | 1 + docs/sprig.md | 24 ++ docs/sprig/conversion.md | 36 +++ docs/sprig/crypto.md | 41 +++ docs/sprig/date.md | 126 ++++++++ docs/sprig/defaults.md | 169 ++++++++++ docs/sprig/dicts.md | 172 ++++++++++ docs/sprig/encoding.md | 6 + docs/sprig/flow_control.md | 11 + docs/sprig/integer_slice.md | 41 +++ docs/sprig/lists.md | 188 +++++++++++ docs/sprig/math.md | 78 +++++ docs/sprig/os.md | 24 ++ docs/sprig/paths.md | 114 +++++++ docs/sprig/reflection.md | 50 +++ docs/sprig/semver.md | 151 +++++++++ docs/sprig/string_slice.md | 72 +++++ docs/sprig/strings.md | 309 ++++++++++++++++++ docs/sprig/url.md | 33 ++ docs/sprig/uuid.md | 9 + mkdocs.yml | 1 + server/server.go | 7 +- server/server_test.go | 45 +++ util/sprig/LICENSE.txt | 19 ++ util/sprig/crypto.go | 37 +++ util/sprig/crypto_test.go | 54 ++++ util/sprig/date.go | 152 +++++++++ util/sprig/date_test.go | 120 +++++++ util/sprig/defaults.go | 163 ++++++++++ util/sprig/defaults_test.go | 196 +++++++++++ util/sprig/dict.go | 118 +++++++ util/sprig/dict_test.go | 166 ++++++++++ util/sprig/doc.go | 19 ++ util/sprig/example_test.go | 25 ++ util/sprig/flow_control_test.go | 16 + util/sprig/functions.go | 302 +++++++++++++++++ util/sprig/functions_linux_test.go | 28 ++ util/sprig/functions_test.go | 70 ++++ util/sprig/functions_windows_test.go | 28 ++ util/sprig/list.go | 464 +++++++++++++++++++++++++++ util/sprig/list_test.go | 364 +++++++++++++++++++++ util/sprig/numeric.go | 228 +++++++++++++ util/sprig/numeric_test.go | 307 ++++++++++++++++++ util/sprig/reflect.go | 28 ++ util/sprig/reflect_test.go | 73 +++++ util/sprig/regex.go | 83 +++++ util/sprig/regex_test.go | 203 ++++++++++++ util/sprig/strings.go | 189 +++++++++++ util/sprig/strings_test.go | 233 ++++++++++++++ util/sprig/url.go | 66 ++++ util/sprig/url_test.go | 87 +++++ 53 files changed, 5550 insertions(+), 2 deletions(-) create mode 100644 docs/sprig.md create mode 100644 docs/sprig/conversion.md create mode 100644 docs/sprig/crypto.md create mode 100644 docs/sprig/date.md create mode 100644 docs/sprig/defaults.md create mode 100644 docs/sprig/dicts.md create mode 100644 docs/sprig/encoding.md create mode 100644 docs/sprig/flow_control.md create mode 100644 docs/sprig/integer_slice.md create mode 100644 docs/sprig/lists.md create mode 100644 docs/sprig/math.md create mode 100644 docs/sprig/os.md create mode 100644 docs/sprig/paths.md create mode 100644 docs/sprig/reflection.md create mode 100644 docs/sprig/semver.md create mode 100644 docs/sprig/string_slice.md create mode 100644 docs/sprig/strings.md create mode 100644 docs/sprig/url.md create mode 100644 docs/sprig/uuid.md create mode 100644 util/sprig/LICENSE.txt create mode 100644 util/sprig/crypto.go create mode 100644 util/sprig/crypto_test.go create mode 100644 util/sprig/date.go create mode 100644 util/sprig/date_test.go create mode 100644 util/sprig/defaults.go create mode 100644 util/sprig/defaults_test.go create mode 100644 util/sprig/dict.go create mode 100644 util/sprig/dict_test.go create mode 100644 util/sprig/doc.go create mode 100644 util/sprig/example_test.go create mode 100644 util/sprig/flow_control_test.go create mode 100644 util/sprig/functions.go create mode 100644 util/sprig/functions_linux_test.go create mode 100644 util/sprig/functions_test.go create mode 100644 util/sprig/functions_windows_test.go create mode 100644 util/sprig/list.go create mode 100644 util/sprig/list_test.go create mode 100644 util/sprig/numeric.go create mode 100644 util/sprig/numeric_test.go create mode 100644 util/sprig/reflect.go create mode 100644 util/sprig/reflect_test.go create mode 100644 util/sprig/regex.go create mode 100644 util/sprig/regex_test.go create mode 100644 util/sprig/strings.go create mode 100644 util/sprig/strings_test.go create mode 100644 util/sprig/url.go create mode 100644 util/sprig/url_test.go diff --git a/README.md b/README.md index 61591ca6..9942e138 100644 --- a/README.md +++ b/README.md @@ -253,3 +253,4 @@ Third-party libraries and resources: * [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) * [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs) * [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications +* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions diff --git a/docs/publish.md b/docs/publish.md index 25bff035..91f75e3d 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -953,13 +953,16 @@ is valid JSON). You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`) to `yes` or `1`, or (more appropriately for webhooks) by setting the `?template=yes` query parameter. Then, include templates in your `message` and/or `title`, using the following stanzas (see [Go docs](https://pkg.go.dev/text/template) for detailed syntax): -* Variables,, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` +* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` * Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) * Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). +ntfy supports a subset of the Sprig template functions that are included in the **[Go Template Playground](https://repeatit.io)**. Please see +[Template Functions](sprig.md) for a list of supported template functions. + !!! info Please note that the Go templating language is quite terrible. My apologies for using it for this feature. It is the best option for Go-based programs like ntfy. Stay calm and don't harm yourself or others in despair. **You can do it. I believe in you!** diff --git a/docs/releases.md b/docs/releases.md index 0877527e..ed728fcb 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1440,6 +1440,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) * Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) * Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) +* You can now use [Slim-Sprig](https://github.com/go-task/slim-sprig) functions in message/title templates ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) **Languages** diff --git a/docs/sprig.md b/docs/sprig.md new file mode 100644 index 00000000..be4e6c9c --- /dev/null +++ b/docs/sprig.md @@ -0,0 +1,24 @@ +# Template Functions + +ntfy includes a (reduced) version of [Sprig](https://github.com/Masterminds/sprig) to add functions that can be used +when you are using the [message template](publish.md#message-templating) feature. + +Below are the functions that are available to use inside your message/title templates. + +* [String Functions](./sprig/strings.md): `trim`, `trunc`, `substr`, `plural`, etc. + * [String List Functions](./sprig/string_slice.md): `splitList`, `sortAlpha`, etc. +* [Integer Math Functions](./sprig/math.md): `add`, `max`, `mul`, etc. + * [Integer List Functions](./sprig/integer_slice.md): `until`, `untilStep` +* [Date Functions](./sprig/date.md): `now`, `date`, etc. +* [Defaults Functions](./sprig/defaults.md): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` +* [Encoding Functions](./sprig/encoding.md): `b64enc`, `b64dec`, etc. +* [Lists and List Functions](./sprig/lists.md): `list`, `first`, `uniq`, etc. +* [Dictionaries and Dict Functions](./sprig/dicts.md): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. +* [Type Conversion Functions](./sprig/conversion.md): `atoi`, `int64`, `toString`, etc. +* [Path and Filepath Functions](./sprig/paths.md): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` +* [Flow Control Functions](./sprig/flow_control.md): `fail` +* Advanced Functions + * [UUID Functions](./sprig/uuid.md): `uuidv4` + * [Reflection](./sprig/reflection.md): `typeOf`, `kindIs`, `typeIsLike`, etc. + * [Cryptographic and Security Functions](./sprig/crypto.md): `sha256sum`, etc. + * [URL](./sprig/url.md): `urlParse`, `urlJoin` diff --git a/docs/sprig/conversion.md b/docs/sprig/conversion.md new file mode 100644 index 00000000..af952682 --- /dev/null +++ b/docs/sprig/conversion.md @@ -0,0 +1,36 @@ +# Type Conversion Functions + +The following type conversion functions are provided by Sprig: + +- `atoi`: Convert a string to an integer. +- `float64`: Convert to a `float64`. +- `int`: Convert to an `int` at the system's width. +- `int64`: Convert to an `int64`. +- `toDecimal`: Convert a unix octal to a `int64`. +- `toString`: Convert to a string. +- `toStrings`: Convert a list, slice, or array to a list of strings. + +Only `atoi` requires that the input be a specific type. The others will attempt +to convert from any type to the destination type. For example, `int64` can convert +floats to ints, and it can also convert strings to ints. + +## toStrings + +Given a list-like collection, produce a slice of strings. + +``` +list 1 2 3 | toStrings +``` + +The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns +them as a list. + +## toDecimal + +Given a unix octal permission, produce a decimal. + +``` +"0777" | toDecimal +``` + +The above converts `0777` to `511` and returns the value as an int64. diff --git a/docs/sprig/crypto.md b/docs/sprig/crypto.md new file mode 100644 index 00000000..c66a269d --- /dev/null +++ b/docs/sprig/crypto.md @@ -0,0 +1,41 @@ +# Cryptographic and Security Functions + +Sprig provides a couple of advanced cryptographic functions. + +## sha1sum + +The `sha1sum` function receives a string, and computes it's SHA1 digest. + +``` +sha1sum "Hello world!" +``` + +## sha256sum + +The `sha256sum` function receives a string, and computes it's SHA256 digest. + +``` +sha256sum "Hello world!" +``` + +The above will compute the SHA 256 sum in an "ASCII armored" format that is +safe to print. + +## sha512sum + +The `sha512sum` function receives a string, and computes it's SHA512 digest. + +``` +sha512sum "Hello world!" +``` + +The above will compute the SHA 512 sum in an "ASCII armored" format that is +safe to print. + +## adler32sum + +The `adler32sum` function receives a string, and computes its Adler-32 checksum. + +``` +adler32sum "Hello world!" +``` diff --git a/docs/sprig/date.md b/docs/sprig/date.md new file mode 100644 index 00000000..7410c08d --- /dev/null +++ b/docs/sprig/date.md @@ -0,0 +1,126 @@ +# Date Functions + +## now + +The current date/time. Use this in conjunction with other date functions. + +## ago + +The `ago` function returns duration from time.Now in seconds resolution. + +``` +ago .CreatedAt +``` + +returns in `time.Duration` String() format + +``` +2h34m7s +``` + +## date + +The `date` function formats a date. + +Format the date to YEAR-MONTH-DAY: + +``` +now | date "2006-01-02" +``` + +Date formatting in Go is a [little bit different](https://pauladamsmith.com/blog/2011/05/go_time.html). + +In short, take this as the base date: + +``` +Mon Jan 2 15:04:05 MST 2006 +``` + +Write it in the format you want. Above, `2006-01-02` is the same date, but +in the format we want. + +## dateInZone + +Same as `date`, but with a timezone. + +``` +dateInZone "2006-01-02" (now) "UTC" +``` + +## duration + +Formats a given amount of seconds as a `time.Duration`. + +This returns 1m35s + +``` +duration "95" +``` + +## durationRound + +Rounds a given duration to the most significant unit. Strings and `time.Duration` +gets parsed as a duration, while a `time.Time` is calculated as the duration since. + +This return 2h + +``` +durationRound "2h10m5s" +``` + +This returns 3mo + +``` +durationRound "2400h10m5s" +``` + +## unixEpoch + +Returns the seconds since the unix epoch for a `time.Time`. + +``` +now | unixEpoch +``` + +## dateModify, mustDateModify + +The `dateModify` takes a modification and a date and returns the timestamp. + +Subtract an hour and thirty minutes from the current time: + +``` +now | date_modify "-1.5h" +``` + +If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. + +## htmlDate + +The `htmlDate` function formats a date for inserting into an HTML date picker +input field. + +``` +now | htmlDate +``` + +## htmlDateInZone + +Same as htmlDate, but with a timezone. + +``` +htmlDateInZone (now) "UTC" +``` + +## toDate, mustToDate + +`toDate` converts a string to a date. The first argument is the date layout and +the second the date string. If the string can't be convert it returns the zero +value. +`mustToDate` will return an error in case the string cannot be converted. + +This is useful when you want to convert a string date to another format +(using pipe). The example below converts "2017-12-31" to "31/12/2017". + +``` +toDate "2006-01-02" "2017-12-31" | date "02/01/2006" +``` diff --git a/docs/sprig/defaults.md b/docs/sprig/defaults.md new file mode 100644 index 00000000..b8af1455 --- /dev/null +++ b/docs/sprig/defaults.md @@ -0,0 +1,169 @@ +# Default Functions + +Sprig provides tools for setting default values for templates. + +## default + +To set a simple default value, use `default`: + +``` +default "foo" .Bar +``` + +In the above, if `.Bar` evaluates to a non-empty value, it will be used. But if +it is empty, `foo` will be returned instead. + +The definition of "empty" depends on type: + +- Numeric: 0 +- String: "" +- Lists: `[]` +- Dicts: `{}` +- Boolean: `false` +- And always `nil` (aka null) + +For structs, there is no definition of empty, so a struct will never return the +default. + +## empty + +The `empty` function returns `true` if the given value is considered empty, and +`false` otherwise. The empty values are listed in the `default` section. + +``` +empty .Foo +``` + +Note that in Go template conditionals, emptiness is calculated for you. Thus, +you rarely need `if empty .Foo`. Instead, just use `if .Foo`. + +## coalesce + +The `coalesce` function takes a list of values and returns the first non-empty +one. + +``` +coalesce 0 1 2 +``` + +The above returns `1`. + +This function is useful for scanning through multiple variables or values: + +``` +coalesce .name .parent.name "Matt" +``` + +The above will first check to see if `.name` is empty. If it is not, it will return +that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. +Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. + +## all + +The `all` function takes a list of values and returns true if all values are non-empty. + +``` +all 0 1 2 +``` + +The above returns `false`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Method "POST") +``` + +The above will check http.Request is POST with tls 1.3 and http/2. + +## any + +The `any` function takes a list of values and returns true if any value is non-empty. + +``` +any 0 1 2 +``` + +The above returns `true`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method "OPTIONS") +``` + +The above will check http.Request method is one of GET/POST/OPTIONS. + +## fromJSON, mustFromJSON + +`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. +`mustFromJSON` will return an error in case the JSON is invalid. + +``` +fromJSON "{\"foo\": 55}" +``` + +## toJSON, mustToJSON + +The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. +`mustToJSON` will return an error in case the item cannot be encoded in JSON. + +``` +toJSON .Item +``` + +The above returns JSON string representation of `.Item`. + +## toPrettyJSON, mustToPrettyJSON + +The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. + +``` +toPrettyJSON .Item +``` + +The above returns indented JSON string representation of `.Item`. + +## toRawJSON, mustToRawJSON + +The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. + +``` +toRawJSON .Item +``` + +The above returns unescaped JSON string representation of `.Item`. + +## ternary + +The `ternary` function takes two values, and a test value. If the test value is +true, the first value will be returned. If the test value is empty, the second +value will be returned. This is similar to the c ternary operator. + +### true test value + +``` +ternary "foo" "bar" true +``` + +or + +``` +true | ternary "foo" "bar" +``` + +The above returns `"foo"`. + +### false test value + +``` +ternary "foo" "bar" false +``` + +or + +``` +false | ternary "foo" "bar" +``` + +The above returns `"bar"`. diff --git a/docs/sprig/dicts.md b/docs/sprig/dicts.md new file mode 100644 index 00000000..5a4490d5 --- /dev/null +++ b/docs/sprig/dicts.md @@ -0,0 +1,172 @@ +# Dictionaries and Dict Functions + +Sprig provides a key/value storage type called a `dict` (short for "dictionary", +as in Python). A `dict` is an _unorder_ type. + +The key to a dictionary **must be a string**. However, the value can be any +type, even another `dict` or `list`. + +Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will +modify the contents of a dictionary. + +## dict + +Creating dictionaries is done by calling the `dict` function and passing it a +list of pairs. + +The following creates a dictionary with three items: + +``` +$myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" +``` + +## get + +Given a map and a key, get the value from the map. + +``` +get $myDict "name1" +``` + +The above returns `"value1"` + +Note that if the key is not found, this operation will simply return `""`. No error +will be generated. + +## set + +Use `set` to add a new key/value pair to a dictionary. + +``` +$_ := set $myDict "name4" "value4" +``` + +Note that `set` _returns the dictionary_ (a requirement of Go template functions), +so you may need to trap the value as done above with the `$_` assignment. + +## unset + +Given a map and a key, delete the key from the map. + +``` +$_ := unset $myDict "name4" +``` + +As with `set`, this returns the dictionary. + +Note that if the key is not found, this operation will simply return. No error +will be generated. + +## hasKey + +The `hasKey` function returns `true` if the given dict contains the given key. + +``` +hasKey $myDict "name1" +``` + +If the key is not found, this returns `false`. + +## pluck + +The `pluck` function makes it possible to give one key and multiple maps, and +get a list of all of the matches: + +``` +pluck "name1" $myDict $myOtherDict +``` + +The above will return a `list` containing every found value (`[value1 otherValue1]`). + +If the give key is _not found_ in a map, that map will not have an item in the +list (and the length of the returned list will be less than the number of dicts +in the call to `pluck`. + +If the key is _found_ but the value is an empty value, that value will be +inserted. + +A common idiom in Sprig templates is to uses `pluck... | first` to get the first +matching key out of a collection of dictionaries. + +## dig + +The `dig` function traverses a nested set of dicts, selecting keys from a list +of values. It returns a default value if any of the keys are not found at the +associated dict. + +``` +dig "user" "role" "humanName" "guest" $dict +``` + +Given a dict structured like +``` +{ + user: { + role: { + humanName: "curator" + } + } +} +``` + +the above would return `"curator"`. If the dict lacked even a `user` field, +the result would be `"guest"`. + +Dig can be very useful in cases where you'd like to avoid guard clauses, +especially since Go's template package's `and` doesn't shortcut. For instance +`and a.maybeNil a.maybeNil.iNeedThis` will always evaluate +`a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) + +`dig` accepts its dict argument last in order to support pipelining. + +## keys + +The `keys` function will return a `list` of all of the keys in one or more `dict` +types. Since a dictionary is _unordered_, the keys will not be in a predictable order. +They can be sorted with `sortAlpha`. + +``` +keys $myDict | sortAlpha +``` + +When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq` +function along with `sortAlpha` to get a unqiue, sorted list of keys. + +``` +keys $myDict $myOtherDict | uniq | sortAlpha +``` + +## pick + +The `pick` function selects just the given keys out of a dictionary, creating a +new `dict`. + +``` +$new := pick $myDict "name1" "name2" +``` + +The above returns `{name1: value1, name2: value2}` + +## omit + +The `omit` function is similar to `pick`, except it returns a new `dict` with all +the keys that _do not_ match the given keys. + +``` +$new := omit $myDict "name1" "name3" +``` + +The above returns `{name2: value2}` + +## values + +The `values` function is similar to `keys`, except it returns a new `list` with +all the values of the source `dict` (only one dictionary is supported). + +``` +$vals := values $myDict +``` + +The above returns `list["value1", "value2", "value 3"]`. Note that the `values` +function gives no guarantees about the result ordering- if you care about this, +then use `sortAlpha`. diff --git a/docs/sprig/encoding.md b/docs/sprig/encoding.md new file mode 100644 index 00000000..1c7a36f8 --- /dev/null +++ b/docs/sprig/encoding.md @@ -0,0 +1,6 @@ +# Encoding Functions + +Sprig has the following encoding and decoding functions: + +- `b64enc`/`b64dec`: Encode or decode with Base64 +- `b32enc`/`b32dec`: Encode or decode with Base32 diff --git a/docs/sprig/flow_control.md b/docs/sprig/flow_control.md new file mode 100644 index 00000000..6414640a --- /dev/null +++ b/docs/sprig/flow_control.md @@ -0,0 +1,11 @@ +# Flow Control Functions + +## fail + +Unconditionally returns an empty `string` and an `error` with the specified +text. This is useful in scenarios where other conditionals have determined that +template rendering should fail. + +``` +fail "Please accept the end user license agreement" +``` diff --git a/docs/sprig/integer_slice.md b/docs/sprig/integer_slice.md new file mode 100644 index 00000000..ab4bef6d --- /dev/null +++ b/docs/sprig/integer_slice.md @@ -0,0 +1,41 @@ +# Integer List Functions + +## until + +The `until` function builds a range of integers. + +``` +until 5 +``` + +The above generates the list `[0, 1, 2, 3, 4]`. + +This is useful for looping with `range $i, $e := until 5`. + +## untilStep + +Like `until`, `untilStep` generates a list of counting integers. But it allows +you to define a start, stop, and step: + +``` +untilStep 3 6 2 +``` + +The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal +or greater than 6. This is similar to Python's `range` function. + +## seq + +Works like the bash `seq` command. +* 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. +* 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. +* 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. + +``` +seq 5 => 1 2 3 4 5 +seq -3 => 1 0 -1 -2 -3 +seq 0 2 => 0 1 2 +seq 2 -2 => 2 1 0 -1 -2 +seq 0 2 10 => 0 2 4 6 8 10 +seq 0 -2 -5 => 0 -2 -4 +``` diff --git a/docs/sprig/lists.md b/docs/sprig/lists.md new file mode 100644 index 00000000..ed8c52b3 --- /dev/null +++ b/docs/sprig/lists.md @@ -0,0 +1,188 @@ +# Lists and List Functions + +Sprig provides a simple `list` type that can contain arbitrary sequential lists +of data. This is similar to arrays or slices, but lists are designed to be used +as immutable data types. + +Create a list of integers: + +``` +$myList := list 1 2 3 4 5 +``` + +The above creates a list of `[1 2 3 4 5]`. + +## first, mustFirst + +To get the head item on a list, use `first`. + +`first $myList` returns `1` + +`first` panics if there is a problem while `mustFirst` returns an error to the +template engine if there is a problem. + +## rest, mustRest + +To get the tail of the list (everything but the first item), use `rest`. + +`rest $myList` returns `[2 3 4 5]` + +`rest` panics if there is a problem while `mustRest` returns an error to the +template engine if there is a problem. + +## last, mustLast + +To get the last item on a list, use `last`: + +`last $myList` returns `5`. This is roughly analogous to reversing a list and +then calling `first`. + +`last` panics if there is a problem while `mustLast` returns an error to the +template engine if there is a problem. + +## initial, mustInitial + +This compliments `last` by returning all _but_ the last element. +`initial $myList` returns `[1 2 3 4]`. + +`initial` panics if there is a problem while `mustInitial` returns an error to the +template engine if there is a problem. + +## append, mustAppend + +Append a new item to an existing list, creating a new list. + +``` +$new = append $myList 6 +``` + +The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. + +`append` panics if there is a problem while `mustAppend` returns an error to the +template engine if there is a problem. + +## prepend, mustPrepend + +Push an element onto the front of a list, creating a new list. + +``` +prepend $myList 0 +``` + +The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. + +`prepend` panics if there is a problem while `mustPrepend` returns an error to the +template engine if there is a problem. + +## concat + +Concatenate arbitrary number of lists into one. + +``` +concat $myList ( list 6 7 ) ( list 8 ) +``` + +The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. + +## reverse, mustReverse + +Produce a new list with the reversed elements of the given list. + +``` +reverse $myList +``` + +The above would generate the list `[5 4 3 2 1]`. + +`reverse` panics if there is a problem while `mustReverse` returns an error to the +template engine if there is a problem. + +## uniq, mustUniq + +Generate a list with all of the duplicates removed. + +``` +list 1 1 1 2 | uniq +``` + +The above would produce `[1 2]` + +`uniq` panics if there is a problem while `mustUniq` returns an error to the +template engine if there is a problem. + +## without, mustWithout + +The `without` function filters items out of a list. + +``` +without $myList 3 +``` + +The above would produce `[1 2 4 5]` + +Without can take more than one filter: + +``` +without $myList 1 3 5 +``` + +That would produce `[2 4]` + +`without` panics if there is a problem while `mustWithout` returns an error to the +template engine if there is a problem. + +## has, mustHas + +Test to see if a list has a particular element. + +``` +has 4 $myList +``` + +The above would return `true`, while `has "hello" $myList` would return false. + +`has` panics if there is a problem while `mustHas` returns an error to the +template engine if there is a problem. + +## compact, mustCompact + +Accepts a list and removes entries with empty values. + +``` +$list := list 1 "a" "foo" "" +$copy := compact $list +``` + +`compact` will return a new list with the empty (i.e., "") item removed. + +`compact` panics if there is a problem and `mustCompact` returns an error to the +template engine if there is a problem. + +## slice, mustSlice + +To get partial elements of a list, use `slice list [n] [m]`. It is +equivalent of `list[n:m]`. + +- `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. +- `slice $myList 3` returns `[4 5]`. It is same as `myList[3:]`. +- `slice $myList 1 3` returns `[2 3]`. It is same as `myList[1:3]`. +- `slice $myList 0 3` returns `[1 2 3]`. It is same as `myList[:3]`. + +`slice` panics if there is a problem while `mustSlice` returns an error to the +template engine if there is a problem. + +## chunk + +To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. + +``` +chunk 3 (list 1 2 3 4 5 6 7 8) +``` + +This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. + +## A Note on List Internals + +A list is implemented in Go as a `[]interface{}`. For Go developers embedding +Sprig, you may pass `[]interface{}` items into your template context and be +able to use all of the `list` functions on those items. diff --git a/docs/sprig/math.md b/docs/sprig/math.md new file mode 100644 index 00000000..b08d0a2f --- /dev/null +++ b/docs/sprig/math.md @@ -0,0 +1,78 @@ +# Integer Math Functions + +The following math functions operate on `int64` values. + +## add + +Sum numbers with `add`. Accepts two or more inputs. + +``` +add 1 2 3 +``` + +## add1 + +To increment by 1, use `add1` + +## sub + +To subtract, use `sub` + +## div + +Perform integer division with `div` + +## mod + +Modulo with `mod` + +## mul + +Multiply with `mul`. Accepts two or more inputs. + +``` +mul 1 2 3 +``` + +## max + +Return the largest of a series of integers: + +This will return `3`: + +``` +max 1 2 3 +``` + +## min + +Return the smallest of a series of integers. + +`min 1 2 3` will return `1` + +## floor + +Returns the greatest float value less than or equal to input value + +`floor 123.9999` will return `123.0` + +## ceil + +Returns the greatest float value greater than or equal to input value + +`ceil 123.001` will return `124.0` + +## round + +Returns a float value with the remainder rounded to the given number to digits after the decimal point. + +`round 123.555555 3` will return `123.556` + +## randInt +Returns a random integer value from min (inclusive) to max (exclusive). + +``` +randInt 12 30 +``` + +The above will produce a random number in the range [12,30]. diff --git a/docs/sprig/os.md b/docs/sprig/os.md new file mode 100644 index 00000000..e6120c03 --- /dev/null +++ b/docs/sprig/os.md @@ -0,0 +1,24 @@ +# OS Functions + +_WARNING:_ These functions can lead to information leakage if not used +appropriately. + +_WARNING:_ Some notable implementations of Sprig (such as +[Kubernetes Helm](http://helm.sh)) _do not provide these functions for security +reasons_. + +## env + +The `env` function reads an environment variable: + +``` +env "HOME" +``` + +## expandenv + +To substitute environment variables in a string, use `expandenv`: + +``` +expandenv "Your path is set to $PATH" +``` diff --git a/docs/sprig/paths.md b/docs/sprig/paths.md new file mode 100644 index 00000000..f847e357 --- /dev/null +++ b/docs/sprig/paths.md @@ -0,0 +1,114 @@ +# Path and Filepath Functions + +While Sprig does not grant access to the filesystem, it does provide functions +for working with strings that follow file path conventions. + +## Paths + +Paths separated by the slash character (`/`), processed by the `path` package. + +Examples: + +* The [Linux](https://en.wikipedia.org/wiki/Linux) and + [MacOS](https://en.wikipedia.org/wiki/MacOS) + [filesystems](https://en.wikipedia.org/wiki/File_system): + `/home/user/file`, `/etc/config`; +* The path component of + [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): + `https://example.com/some/content/`, `ftp://example.com/file/`. + +### base + +Return the last element of a path. + +``` +base "foo/bar/baz" +``` + +The above prints "baz". + +### dir + +Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` +returns `foo/bar`. + +### clean + +Clean up a path. + +``` +clean "foo/bar/../baz" +``` + +The above resolves the `..` and returns `foo/baz`. + +### ext + +Return the file extension. + +``` +ext "foo.bar" +``` + +The above returns `.bar`. + +### isAbs + +To check whether a path is absolute, use `isAbs`. + +## Filepaths + +Paths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package. + +These are the recommended functions to use when parsing paths of local filesystems, usually when dealing with local files, directories, etc. + +Examples: + +* Running on Linux or MacOS the filesystem path is separated by the slash character (`/`): + `/home/user/file`, `/etc/config`; +* Running on [Windows](https://en.wikipedia.org/wiki/Microsoft_Windows) + the filesystem path is separated by the backslash character (`\`): + `C:\Users\Username\`, `C:\Program Files\Application\`; + +### osBase + +Return the last element of a filepath. + +``` +osBase "/foo/bar/baz" +osBase "C:\\foo\\bar\\baz" +``` + +The above prints "baz" on Linux and Windows, respectively. + +### osDir + +Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` +returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` +returns `C:\\foo\\bar` on Windows. + +### osClean + +Clean up a path. + +``` +osClean "/foo/bar/../baz" +osClean "C:\\foo\\bar\\..\\baz" +``` + +The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. + +### osExt + +Return the file extension. + +``` +osExt "/foo.bar" +osExt "C:\\foo.bar" +``` + +The above returns `.bar` on Linux and Windows, respectively. + +### osIsAbs + +To check whether a file path is absolute, use `osIsAbs`. diff --git a/docs/sprig/reflection.md b/docs/sprig/reflection.md new file mode 100644 index 00000000..51e167aa --- /dev/null +++ b/docs/sprig/reflection.md @@ -0,0 +1,50 @@ +# Reflection Functions + +Sprig provides rudimentary reflection tools. These help advanced template +developers understand the underlying Go type information for a particular value. + +Go has several primitive _kinds_, like `string`, `slice`, `int64`, and `bool`. + +Go has an open _type_ system that allows developers to create their own types. + +Sprig provides a set of functions for each. + +## Kind Functions + +There are two Kind functions: `kindOf` returns the kind of an object. + +``` +kindOf "hello" +``` + +The above would return `string`. For simple tests (like in `if` blocks), the +`kindIs` function will let you verify that a value is a particular kind: + +``` +kindIs "int" 123 +``` + +The above will return `true` + +## Type Functions + +Types are slightly harder to work with, so there are three different functions: + +- `typeOf` returns the underlying type of a value: `typeOf $foo` +- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` +- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. + +**Note:** None of these can test whether or not something implements a given +interface, since doing so would require compiling the interface in ahead of time. + +## deepEqual + +`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) + +Works for non-primitive types as well (compared to the built-in `eq`). + +``` +deepEqual (list 1 2 3) (list 1 2 3) +``` + +The above will return `true` diff --git a/docs/sprig/semver.md b/docs/sprig/semver.md new file mode 100644 index 00000000..f049613d --- /dev/null +++ b/docs/sprig/semver.md @@ -0,0 +1,151 @@ +# Semantic Version Functions + +Some version schemes are easily parseable and comparable. Sprig provides functions +for working with [SemVer 2](http://semver.org) versions. + +## semver + +The `semver` function parses a string into a Semantic Version: + +``` +$version := semver "1.2.3-alpha.1+123" +``` + +_If the parser fails, it will cause template execution to halt with an error._ + +At this point, `$version` is a pointer to a `Version` object with the following +properties: + +- `$version.Major`: The major number (`1` above) +- `$version.Minor`: The minor number (`2` above) +- `$version.Patch`: The patch number (`3` above) +- `$version.Prerelease`: The prerelease (`alpha.1` above) +- `$version.Metadata`: The build metadata (`123` above) +- `$version.Original`: The original version as a string + +Additionally, you can compare a `Version` to another `version` using the `Compare` +function: + +``` +semver "1.4.3" | (semver "1.2.3").Compare +``` + +The above will return `-1`. + +The return values are: + +- `-1` if the given semver is greater than the semver whose `Compare` method was called +- `1` if the version who's `Compare` function was called is greater. +- `0` if they are the same version + +(Note that in SemVer, the `Metadata` field is not compared during version +comparison operations.) + +## semverCompare + +A more robust comparison function is provided as `semverCompare`. It returns `true` if +the constraint matches, or `false` if it does not match. This version supports version ranges: + +- `semverCompare "1.2.3" "1.2.3"` checks for an exact match +- `semverCompare "^1.2.0" "1.2.3"` checks that the major and minor versions match, and that the patch + number of the second version is _greater than or equal to_ the first parameter. + +The SemVer functions use the [Masterminds semver library](https://github.com/Masterminds/semver), +from the creators of Sprig. + +## Basic Comparisons + +There are two elements to the comparisons. First, a comparison string is a list +of space or comma separated AND comparisons. These are then separated by || (OR) +comparisons. For example, `">= 1.2 < 3.0.0 || >= 4.2.3"` is looking for a +comparison that's greater than or equal to 1.2 and less than 3.0.0 or is +greater than or equal to 4.2.3. + +The basic comparisons are: + +- `=`: equal (aliased to no operator) +- `!=`: not equal +- `>`: greater than +- `<`: less than +- `>=`: greater than or equal to +- `<=`: less than or equal to + +_Note, according to the Semantic Version specification pre-releases may not be +API compliant with their release counterpart. It says,_ + +## Working With Prerelease Versions + +Pre-releases, for those not familiar with them, are used for software releases +prior to stable or generally available releases. Examples of prereleases include +development, alpha, beta, and release candidate releases. A prerelease may be +a version such as `1.2.3-beta.1` while the stable release would be `1.2.3`. In the +order of precedence, prereleases come before their associated releases. In this +example `1.2.3-beta.1 < 1.2.3`. + +According to the Semantic Version specification prereleases may not be +API compliant with their release counterpart. It says, + +> A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version. + +SemVer comparisons using constraints without a prerelease comparator will skip +prerelease versions. For example, `>=1.2.3` will skip prereleases when looking +at a list of releases while `>=1.2.3-0` will evaluate and find prereleases. + +The reason for the `0` as a pre-release version in the example comparison is +because pre-releases can only contain ASCII alphanumerics and hyphens (along with +`.` separators), per the spec. Sorting happens in ASCII sort order, again per the +spec. The lowest character is a `0` in ASCII sort order +(see an [ASCII Table](http://www.asciitable.com/)) + +Understanding ASCII sort ordering is important because A-Z comes before a-z. That +means `>=1.2.3-BETA` will return `1.2.3-alpha`. What you might expect from case +sensitivity doesn't apply here. This is due to ASCII sort ordering which is what +the spec specifies. + +## Hyphen Range Comparisons + +There are multiple methods to handle ranges and the first is hyphens ranges. +These look like: + +- `1.2 - 1.4.5` which is equivalent to `>= 1.2 <= 1.4.5` +- `2.3.4 - 4.5` which is equivalent to `>= 2.3.4 <= 4.5` + +## Wildcards In Comparisons + +The `x`, `X`, and `*` characters can be used as a wildcard character. This works +for all comparison operators. When used on the `=` operator it falls +back to the patch level comparison (see tilde below). For example, + +- `1.2.x` is equivalent to `>= 1.2.0, < 1.3.0` +- `>= 1.2.x` is equivalent to `>= 1.2.0` +- `<= 2.x` is equivalent to `< 3` +- `*` is equivalent to `>= 0.0.0` + +## Tilde Range Comparisons (Patch) + +The tilde (`~`) comparison operator is for patch level ranges when a minor +version is specified and major level changes when the minor number is missing. +For example, + +- `~1.2.3` is equivalent to `>= 1.2.3, < 1.3.0` +- `~1` is equivalent to `>= 1, < 2` +- `~2.3` is equivalent to `>= 2.3, < 2.4` +- `~1.2.x` is equivalent to `>= 1.2.0, < 1.3.0` +- `~1.x` is equivalent to `>= 1, < 2` + +## Caret Range Comparisons (Major) + +The caret (`^`) comparison operator is for major level changes once a stable +(1.0.0) release has occurred. Prior to a 1.0.0 release the minor versions acts +as the API stability level. This is useful when comparisons of API versions as a +major change is API breaking. For example, + +- `^1.2.3` is equivalent to `>= 1.2.3, < 2.0.0` +- `^1.2.x` is equivalent to `>= 1.2.0, < 2.0.0` +- `^2.3` is equivalent to `>= 2.3, < 3` +- `^2.x` is equivalent to `>= 2.0.0, < 3` +- `^0.2.3` is equivalent to `>=0.2.3 <0.3.0` +- `^0.2` is equivalent to `>=0.2.0 <0.3.0` +- `^0.0.3` is equivalent to `>=0.0.3 <0.0.4` +- `^0.0` is equivalent to `>=0.0.0 <0.1.0` +- `^0` is equivalent to `>=0.0.0 <1.0.0` diff --git a/docs/sprig/string_slice.md b/docs/sprig/string_slice.md new file mode 100644 index 00000000..96c0c83b --- /dev/null +++ b/docs/sprig/string_slice.md @@ -0,0 +1,72 @@ +# String List Functions + +These function operate on or generate slices of strings. In Go, a slice is a +growable array. In Sprig, it's a special case of a `list`. + +## join + +Join a list of strings into a single string, with the given separator. + +``` +list "hello" "world" | join "_" +``` + +The above will produce `hello_world` + +`join` will try to convert non-strings to a string value: + +``` +list 1 2 3 | join "+" +``` + +The above will produce `1+2+3` + +## splitList and split + +Split a string into a list of strings: + +``` +splitList "$" "foo$bar$baz" +``` + +The above will return `[foo bar baz]` + +The older `split` function splits a string into a `dict`. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := split "$" "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar, _2: baz}` + +``` +$a._0 +``` + +The above produces `foo` + +## splitn + +`splitn` function splits a string into a `dict` with `n` keys. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := splitn "$" 2 "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar$baz}` + +``` +$a._0 +``` + +The above produces `foo` + +## sortAlpha + +The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) +order. + +It does _not_ sort in place, but returns a sorted copy of the list, in keeping +with the immutability of lists. diff --git a/docs/sprig/strings.md b/docs/sprig/strings.md new file mode 100644 index 00000000..784392f1 --- /dev/null +++ b/docs/sprig/strings.md @@ -0,0 +1,309 @@ +# String Functions + +Sprig has a number of string manipulation functions. + +## trim + +The `trim` function removes space from either side of a string: + +``` +trim " hello " +``` + +The above produces `hello` + +## trimAll + +Remove given characters from the front or back of a string: + +``` +trimAll "$" "$5.00" +``` + +The above returns `5.00` (as a string). + +## trimSuffix + +Trim just the suffix from a string: + +``` +trimSuffix "-" "hello-" +``` + +The above returns `hello` + +## trimPrefix + +Trim just the prefix from a string: + +``` +trimPrefix "-" "-hello" +``` + +The above returns `hello` + +## upper + +Convert the entire string to uppercase: + +``` +upper "hello" +``` + +The above returns `HELLO` + +## lower + +Convert the entire string to lowercase: + +``` +lower "HELLO" +``` + +The above returns `hello` + +## title + +Convert to title case: + +``` +title "hello world" +``` + +The above returns `Hello World` + +## repeat + +Repeat a string multiple times: + +``` +repeat 3 "hello" +``` + +The above returns `hellohellohello` + +## substr + +Get a substring from a string. It takes three parameters: + +- start (int) +- end (int) +- string (string) + +``` +substr 0 5 "hello world" +``` + +The above returns `hello` + +## trunc + +Truncate a string (and add no suffix) + +``` +trunc 5 "hello world" +``` + +The above produces `hello`. + +``` +trunc -5 "hello world" +``` + +The above produces `world`. + +## contains + +Test to see if one string is contained inside of another: + +``` +contains "cat" "catch" +``` + +The above returns `true` because `catch` contains `cat`. + +## hasPrefix and hasSuffix + +The `hasPrefix` and `hasSuffix` functions test whether a string has a given +prefix or suffix: + +``` +hasPrefix "cat" "catch" +``` + +The above returns `true` because `catch` has the prefix `cat`. + +## quote and squote + +These functions wrap a string in double quotes (`quote`) or single quotes +(`squote`). + +## cat + +The `cat` function concatenates multiple strings together into one, separating +them with spaces: + +``` +cat "hello" "beautiful" "world" +``` + +The above produces `hello beautiful world` + +## indent + +The `indent` function indents every line in a given string to the specified +indent width. This is useful when aligning multi-line strings: + +``` +indent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters. + +## nindent + +The `nindent` function is the same as the indent function, but prepends a new +line to the beginning of the string. + +``` +nindent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters and add a new +line to the beginning. + +## replace + +Perform simple string replacement. + +It takes three arguments: + +- string to replace +- string to replace with +- source string + +``` +"I Am Henry VIII" | replace " " "-" +``` + +The above will produce `I-Am-Henry-VIII` + +## plural + +Pluralize a string. + +``` +len $fish | plural "one anchovy" "many anchovies" +``` + +In the above, if the length of the string is 1, the first argument will be +printed (`one anchovy`). Otherwise, the second argument will be printed +(`many anchovies`). + +The arguments are: + +- singular string +- plural string +- length integer + +NOTE: Sprig does not currently support languages with more complex pluralization +rules. And `0` is considered a plural because the English language treats it +as such (`zero anchovies`). The Sprig developers are working on a solution for +better internationalization. + +## regexMatch, mustRegexMatch + +Returns true if the input string contains any match of the regular expression. + +``` +regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" +``` + +The above produces `true` + +`regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the +template engine if there is a problem. + +## regexFindAll, mustRegexFindAll + +Returns a slice of all matches of the regular expression in the input string. +The last parameter n determines the number of substrings to return, where -1 means return all matches + +``` +regexFindAll "[2,4,6,8]" "123456789" -1 +``` + +The above produces `[2 4 6 8]` + +`regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the +template engine if there is a problem. + +## regexFind, mustRegexFind + +Return the first (left most) match of the regular expression in the input string + +``` +regexFind "[a-zA-Z][1-9]" "abcd1234" +``` + +The above produces `d1` + +`regexFind` panics if there is a problem and `mustRegexFind` returns an error to the +template engine if there is a problem. + +## regexReplaceAll, mustRegexReplaceAll + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. +Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch + +``` +regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" +``` + +The above produces `-W-xxW-` + +`regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the +template engine if there is a problem. + +## regexReplaceAllLiteral, mustRegexReplaceAllLiteral + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement +The replacement string is substituted directly, without using Expand + +``` +regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}" +``` + +The above produces `-${1}-${1}-` + +`regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the +template engine if there is a problem. + +## regexSplit, mustRegexSplit + +Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches + +``` +regexSplit "z+" "pizza" -1 +``` + +The above produces `[pi a]` + +`regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the +template engine if there is a problem. + +## regexQuoteMeta + +Returns a string that escapes all regular expression metacharacters inside the argument text; +the returned string is a regular expression matching the literal text. + +``` +regexQuoteMeta "1.2.3" +``` + +The above produces `1\.2\.3` + +## See Also... + +The [Conversion Functions](conversion.md) contain functions for converting strings. The [String List Functions](string_slice.md) contains +functions for working with an array of strings. diff --git a/docs/sprig/url.md b/docs/sprig/url.md new file mode 100644 index 00000000..21d54a29 --- /dev/null +++ b/docs/sprig/url.md @@ -0,0 +1,33 @@ +# URL Functions + +## urlParse +Parses string for URL and produces dict with URL parts + +``` +urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" +``` + +The above returns a dict, containing URL object: +```yaml +scheme: 'http' +host: 'server.com:8080' +path: '/api' +query: 'list=false' +opaque: nil +fragment: 'anchor' +userinfo: 'admin:secret' +``` + +For more info, check https://golang.org/pkg/net/url/#URL + +## urlJoin +Joins map (produced by `urlParse`) to produce URL string + +``` +urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") +``` + +The above returns the following string: +``` +proto://host:80/path?query#fragment +``` diff --git a/docs/sprig/uuid.md b/docs/sprig/uuid.md new file mode 100644 index 00000000..1b57a330 --- /dev/null +++ b/docs/sprig/uuid.md @@ -0,0 +1,9 @@ +# UUID Functions + +Sprig can generate UUID v4 universally unique IDs. + +``` +uuidv4 +``` + +The above returns a new UUID of the v4 (randomly generated) type. diff --git a/mkdocs.yml b/mkdocs.yml index ef746518..8006eac4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - "Integrations + projects": integrations.md - "Release notes": releases.md - "Emojis 🥳 🎉": emojis.md + - "Template Functions": sprig.md - "Troubleshooting": troubleshooting.md - "Known issues": known-issues.md - "Deprecation notices": deprecations.md diff --git a/server/server.go b/server/server.go index bfa7eb6b..94461fbb 100644 --- a/server/server.go +++ b/server/server.go @@ -34,6 +34,7 @@ import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" + "heckel.io/ntfy/v2/util/sprig" ) // Server is the main server, providing the UI and API for ntfy @@ -1132,7 +1133,11 @@ func replaceTemplate(tpl string, source string) (string, error) { if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } - t, err := template.New("").Parse(tpl) + sprigFuncs := sprig.FuncMap() + // remove unsafe functions + delete(sprigFuncs, "env") + delete(sprigFuncs, "expandenv") + t, err := template.New("").Funcs(sprigFuncs).Parse(tpl) if err != nil { return "", errHTTPBadRequestTemplateInvalid } diff --git a/server/server_test.go b/server/server_test.go index e09f67a2..4fa059b6 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3024,6 +3024,51 @@ template ""}}`, } } +func TestServer_MessageTemplate_SprigFunctions(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + bodies := []string{ + `{"foo":"bar","nested":{"title":"here"}}`, + `{"topic":"ntfy-test"}`, + `{"topic":"another-topic"}`, + } + templates := []string{ + `{{.foo | upper}} is {{.nested.title | repeat 3}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + `{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`, + } + targets := []string{ + `BAR is hereherehere`, + `Topic: test`, + `Topic: another-topic`, + } + for i, body := range bodies { + template := templates[i] + target := targets[i] + t.Run(template, func(t *testing.T) { + response := request(t, s, "PUT", `/mytopic`, body, map[string]string{ + "Template": "yes", + "Message": template, + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, target, m.Message) + }) + } +} + +func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ env "PATH" }}`, + "X-Template": "1", + }) + + require.Equal(t, 400, response.Code) + require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/util/sprig/LICENSE.txt b/util/sprig/LICENSE.txt new file mode 100644 index 00000000..f311b1ea --- /dev/null +++ b/util/sprig/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (C) 2013-2020 Masterminds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/util/sprig/crypto.go b/util/sprig/crypto.go new file mode 100644 index 00000000..4d027781 --- /dev/null +++ b/util/sprig/crypto.go @@ -0,0 +1,37 @@ +package sprig + +import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "fmt" + "hash/adler32" + + "github.com/google/uuid" +) + +func sha512sum(input string) string { + hash := sha512.Sum512([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +func sha256sum(input string) string { + hash := sha256.Sum256([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +func sha1sum(input string) string { + hash := sha1.Sum([]byte(input)) + return hex.EncodeToString(hash[:]) +} + +func adler32sum(input string) string { + hash := adler32.Checksum([]byte(input)) + return fmt.Sprintf("%d", hash) +} + +// uuidv4 provides a safe and secure UUID v4 implementation +func uuidv4() string { + return uuid.New().String() +} diff --git a/util/sprig/crypto_test.go b/util/sprig/crypto_test.go new file mode 100644 index 00000000..bad809a5 --- /dev/null +++ b/util/sprig/crypto_test.go @@ -0,0 +1,54 @@ +package sprig + +import ( + "testing" +) + +func TestSha512Sum(t *testing.T) { + tpl := `{{"abc" | sha512sum}}` + if err := runt(tpl, "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"); err != nil { + t.Error(err) + } +} + +func TestSha256Sum(t *testing.T) { + tpl := `{{"abc" | sha256sum}}` + if err := runt(tpl, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil { + t.Error(err) + } +} + +func TestSha1Sum(t *testing.T) { + tpl := `{{"abc" | sha1sum}}` + if err := runt(tpl, "a9993e364706816aba3e25717850c26c9cd0d89d"); err != nil { + t.Error(err) + } +} + +func TestAdler32Sum(t *testing.T) { + tpl := `{{"abc" | adler32sum}}` + if err := runt(tpl, "38600999"); err != nil { + t.Error(err) + } +} + +func TestUUIDGeneration(t *testing.T) { + tpl := `{{uuidv4}}` + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + + if len(out) != 36 { + t.Error("Expected UUID of length 36") + } + + out2, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + + if out == out2 { + t.Error("Expected subsequent UUID generations to be different") + } +} diff --git a/util/sprig/date.go b/util/sprig/date.go new file mode 100644 index 00000000..ed022dda --- /dev/null +++ b/util/sprig/date.go @@ -0,0 +1,152 @@ +package sprig + +import ( + "strconv" + "time" +) + +// Given a format and a date, format the date string. +// +// Date can be a `time.Time` or an `int, int32, int64`. +// In the later case, it is treated as seconds since UNIX +// epoch. +func date(fmt string, date interface{}) string { + return dateInZone(fmt, date, "Local") +} + +func htmlDate(date interface{}) string { + return dateInZone("2006-01-02", date, "Local") +} + +func htmlDateInZone(date interface{}, zone string) string { + return dateInZone("2006-01-02", date, zone) +} + +func dateInZone(fmt string, date interface{}, zone string) string { + var t time.Time + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case *time.Time: + t = *date + case int64: + t = time.Unix(date, 0) + case int: + t = time.Unix(int64(date), 0) + case int32: + t = time.Unix(int64(date), 0) + } + + loc, err := time.LoadLocation(zone) + if err != nil { + loc, _ = time.LoadLocation("UTC") + } + + return t.In(loc).Format(fmt) +} + +func dateModify(fmt string, date time.Time) time.Time { + d, err := time.ParseDuration(fmt) + if err != nil { + return date + } + return date.Add(d) +} + +func mustDateModify(fmt string, date time.Time) (time.Time, error) { + d, err := time.ParseDuration(fmt) + if err != nil { + return time.Time{}, err + } + return date.Add(d), nil +} + +func dateAgo(date interface{}) string { + var t time.Time + + switch date := date.(type) { + default: + t = time.Now() + case time.Time: + t = date + case int64: + t = time.Unix(date, 0) + case int: + t = time.Unix(int64(date), 0) + } + // Drop resolution to seconds + duration := time.Since(t).Round(time.Second) + return duration.String() +} + +func duration(sec interface{}) string { + var n int64 + switch value := sec.(type) { + default: + n = 0 + case string: + n, _ = strconv.ParseInt(value, 10, 64) + case int64: + n = value + } + return (time.Duration(n) * time.Second).String() +} + +func durationRound(duration interface{}) string { + var d time.Duration + switch duration := duration.(type) { + default: + d = 0 + case string: + d, _ = time.ParseDuration(duration) + case int64: + d = time.Duration(duration) + case time.Time: + d = time.Since(duration) + } + + u := uint64(d) + neg := d < 0 + if neg { + u = -u + } + + var ( + year = uint64(time.Hour) * 24 * 365 + month = uint64(time.Hour) * 24 * 30 + day = uint64(time.Hour) * 24 + hour = uint64(time.Hour) + minute = uint64(time.Minute) + second = uint64(time.Second) + ) + switch { + case u > year: + return strconv.FormatUint(u/year, 10) + "y" + case u > month: + return strconv.FormatUint(u/month, 10) + "mo" + case u > day: + return strconv.FormatUint(u/day, 10) + "d" + case u > hour: + return strconv.FormatUint(u/hour, 10) + "h" + case u > minute: + return strconv.FormatUint(u/minute, 10) + "m" + case u > second: + return strconv.FormatUint(u/second, 10) + "s" + } + return "0s" +} + +func toDate(fmt, str string) time.Time { + t, _ := time.ParseInLocation(fmt, str, time.Local) + return t +} + +func mustToDate(fmt, str string) (time.Time, error) { + return time.ParseInLocation(fmt, str, time.Local) +} + +func unixEpoch(date time.Time) string { + return strconv.FormatInt(date.Unix(), 10) +} diff --git a/util/sprig/date_test.go b/util/sprig/date_test.go new file mode 100644 index 00000000..be7ec9d9 --- /dev/null +++ b/util/sprig/date_test.go @@ -0,0 +1,120 @@ +package sprig + +import ( + "testing" + "time" +) + +func TestHtmlDate(t *testing.T) { + t.Skip() + tpl := `{{ htmlDate 0}}` + if err := runt(tpl, "1970-01-01"); err != nil { + t.Error(err) + } +} + +func TestAgo(t *testing.T) { + tpl := "{{ ago .Time }}" + if err := runtv(tpl, "2m5s", map[string]interface{}{"Time": time.Now().Add(-125 * time.Second)}); err != nil { + t.Error(err) + } + + if err := runtv(tpl, "2h34m17s", map[string]interface{}{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil { + t.Error(err) + } + + if err := runtv(tpl, "-5s", map[string]interface{}{"Time": time.Now().Add(5 * time.Second)}); err != nil { + t.Error(err) + } +} + +func TestToDate(t *testing.T) { + tpl := `{{toDate "2006-01-02" "2017-12-31" | date "02/01/2006"}}` + if err := runt(tpl, "31/12/2017"); err != nil { + t.Error(err) + } +} + +func TestUnixEpoch(t *testing.T) { + tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") + if err != nil { + t.Error(err) + } + tpl := `{{unixEpoch .Time}}` + + if err = runtv(tpl, "1560458379", map[string]interface{}{"Time": tm}); err != nil { + t.Error(err) + } +} + +func TestDateInZone(t *testing.T) { + tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT") + if err != nil { + t.Error(err) + } + tpl := `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "UTC" }}` + + // Test time.Time input + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { + t.Error(err) + } + + // Test pointer to time.Time input + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": &tm}); err != nil { + t.Error(err) + } + + // Test no time input. This should be close enough to time.Now() we can test + loc, _ := time.LoadLocation("UTC") + if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]interface{}{"Time": ""}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int64 + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int64(1560458379)}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int32 + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int32(1560458379)}); err != nil { + t.Error(err) + } + + // Test unix timestamp as int + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int(1560458379)}); err != nil { + t.Error(err) + } + + // Test case of invalid timezone + tpl = `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "foobar" }}` + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { + t.Error(err) + } +} + +func TestDuration(t *testing.T) { + tpl := "{{ duration .Secs }}" + if err := runtv(tpl, "1m1s", map[string]interface{}{"Secs": "61"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "1h0m0s", map[string]interface{}{"Secs": "3600"}); err != nil { + t.Error(err) + } + // 1d2h3m4s but go is opinionated + if err := runtv(tpl, "26h3m4s", map[string]interface{}{"Secs": "93784"}); err != nil { + t.Error(err) + } +} + +func TestDurationRound(t *testing.T) { + tpl := "{{ durationRound .Time }}" + if err := runtv(tpl, "2h", map[string]interface{}{"Time": "2h5s"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "1d", map[string]interface{}{"Time": "24h5s"}); err != nil { + t.Error(err) + } + if err := runtv(tpl, "3mo", map[string]interface{}{"Time": "2400h5s"}); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go new file mode 100644 index 00000000..201b7e24 --- /dev/null +++ b/util/sprig/defaults.go @@ -0,0 +1,163 @@ +package sprig + +import ( + "bytes" + "encoding/json" + "math/rand" + "reflect" + "strings" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// dfault checks whether `given` is set, and returns default if not set. +// +// This returns `d` if `given` appears not to be set, and `given` otherwise. +// +// For numeric types 0 is unset. +// For strings, maps, arrays, and slices, len() = 0 is considered unset. +// For bool, false is unset. +// Structs are never considered unset. +// +// For everything else, including pointers, a nil value is unset. +func dfault(d interface{}, given ...interface{}) interface{} { + + if empty(given) || empty(given[0]) { + return d + } + return given[0] +} + +// empty returns true if the given value has the zero value for its type. +func empty(given interface{}) bool { + g := reflect.ValueOf(given) + if !g.IsValid() { + return true + } + + // Basically adapted from text/template.isTrue + switch g.Kind() { + default: + return g.IsNil() + case reflect.Array, reflect.Slice, reflect.Map, reflect.String: + return g.Len() == 0 + case reflect.Bool: + return !g.Bool() + case reflect.Complex64, reflect.Complex128: + return g.Complex() == 0 + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return g.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return g.Uint() == 0 + case reflect.Float32, reflect.Float64: + return g.Float() == 0 + case reflect.Struct: + return false + } +} + +// coalesce returns the first non-empty value. +func coalesce(v ...interface{}) interface{} { + for _, val := range v { + if !empty(val) { + return val + } + } + return nil +} + +// all returns true if empty(x) is false for all values x in the list. +// If the list is empty, return true. +func all(v ...interface{}) bool { + for _, val := range v { + if empty(val) { + return false + } + } + return true +} + +// any returns true if empty(x) is false for any x in the list. +// If the list is empty, return false. +func any(v ...interface{}) bool { + for _, val := range v { + if !empty(val) { + return true + } + } + return false +} + +// fromJSON decodes JSON into a structured value, ignoring errors. +func fromJSON(v string) interface{} { + output, _ := mustFromJSON(v) + return output +} + +// mustFromJSON decodes JSON into a structured value, returning errors. +func mustFromJSON(v string) (interface{}, error) { + var output interface{} + err := json.Unmarshal([]byte(v), &output) + return output, err +} + +// toJSON encodes an item into a JSON string +func toJSON(v interface{}) string { + output, _ := json.Marshal(v) + return string(output) +} + +func mustToJSON(v interface{}) (string, error) { + output, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(output), nil +} + +// toPrettyJSON encodes an item into a pretty (indented) JSON string +func toPrettyJSON(v interface{}) string { + output, _ := json.MarshalIndent(v, "", " ") + return string(output) +} + +func mustToPrettyJSON(v interface{}) (string, error) { + output, err := json.MarshalIndent(v, "", " ") + if err != nil { + return "", err + } + return string(output), nil +} + +// toRawJSON encodes an item into a JSON string with no escaping of HTML characters. +func toRawJSON(v interface{}) string { + output, err := mustToRawJSON(v) + if err != nil { + panic(err) + } + return string(output) +} + +// mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. +func mustToRawJSON(v interface{}) (string, error) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + err := enc.Encode(&v) + if err != nil { + return "", err + } + return strings.TrimSuffix(buf.String(), "\n"), nil +} + +// ternary returns the first value if the last value is true, otherwise returns the second value. +func ternary(vt interface{}, vf interface{}, v bool) interface{} { + if v { + return vt + } + + return vf +} diff --git a/util/sprig/defaults_test.go b/util/sprig/defaults_test.go new file mode 100644 index 00000000..eb7e35b4 --- /dev/null +++ b/util/sprig/defaults_test.go @@ -0,0 +1,196 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDefault(t *testing.T) { + tpl := `{{"" | default "foo"}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + tpl = `{{default "foo" 234}}` + if err := runt(tpl, "234"); err != nil { + t.Error(err) + } + tpl = `{{default "foo" 2.34}}` + if err := runt(tpl, "2.34"); err != nil { + t.Error(err) + } + + tpl = `{{ .Nothing | default "123" }}` + if err := runt(tpl, "123"); err != nil { + t.Error(err) + } + tpl = `{{ default "123" }}` + if err := runt(tpl, "123"); err != nil { + t.Error(err) + } +} + +func TestEmpty(t *testing.T) { + tpl := `{{if empty 1}}1{{else}}0{{end}}` + if err := runt(tpl, "0"); err != nil { + t.Error(err) + } + + tpl = `{{if empty 0}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty ""}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty 0.0}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + tpl = `{{if empty false}}1{{else}}0{{end}}` + if err := runt(tpl, "1"); err != nil { + t.Error(err) + } + + dict := map[string]interface{}{"top": map[string]interface{}{}} + tpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}` + if err := runtv(tpl, "1", dict); err != nil { + t.Error(err) + } + tpl = `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}` + if err := runtv(tpl, "1", dict); err != nil { + t.Error(err) + } +} + +func TestCoalesce(t *testing.T) { + tests := map[string]string{ + `{{ coalesce 1 }}`: "1", + `{{ coalesce "" 0 nil 2 }}`: "2", + `{{ $two := 2 }}{{ coalesce "" 0 nil $two }}`: "2", + `{{ $two := 2 }}{{ coalesce "" $two 0 0 0 }}`: "2", + `{{ $two := 2 }}{{ coalesce "" $two 3 4 5 }}`: "2", + `{{ coalesce }}`: "", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]interface{}{"top": map[string]interface{}{}} + tpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "airplane", dict); err != nil { + t.Error(err) + } +} + +func TestAll(t *testing.T) { + tests := map[string]string{ + `{{ all 1 }}`: "true", + `{{ all "" 0 nil 2 }}`: "false", + `{{ $two := 2 }}{{ all "" 0 nil $two }}`: "false", + `{{ $two := 2 }}{{ all "" $two 0 0 0 }}`: "false", + `{{ $two := 2 }}{{ all "" $two 3 4 5 }}`: "false", + `{{ all }}`: "true", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]interface{}{"top": map[string]interface{}{}} + tpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "false", dict); err != nil { + t.Error(err) + } +} + +func TestAny(t *testing.T) { + tests := map[string]string{ + `{{ any 1 }}`: "true", + `{{ any "" 0 nil 2 }}`: "true", + `{{ $two := 2 }}{{ any "" 0 nil $two }}`: "true", + `{{ $two := 2 }}{{ any "" $two 3 4 5 }}`: "true", + `{{ $zero := 0 }}{{ any "" $zero 0 0 0 }}`: "false", + `{{ any }}`: "false", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } + + dict := map[string]interface{}{"top": map[string]interface{}{}} + tpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` + if err := runtv(tpl, "true", dict); err != nil { + t.Error(err) + } +} + +func TestFromJSON(t *testing.T) { + dict := map[string]interface{}{"Input": `{"foo": 55}`} + + tpl := `{{.Input | fromJSON}}` + expected := `map[foo:55]` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } + + tpl = `{{(.Input | fromJSON).foo}}` + expected = `55` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToJSON(t *testing.T) { + dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} + + tpl := `{{.Top | toJSON}}` + expected := `{"bool":true,"number":42,"string":"test"}` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToPrettyJSON(t *testing.T) { + dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} + tpl := `{{.Top | toPrettyJSON}}` + expected := `{ + "bool": true, + "number": 42, + "string": "test" +}` + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestToRawJSON(t *testing.T) { + dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42, "html": ""}} + tpl := `{{.Top | toRawJSON}}` + expected := `{"bool":true,"html":"","number":42,"string":"test"}` + + if err := runtv(tpl, expected, dict); err != nil { + t.Error(err) + } +} + +func TestTernary(t *testing.T) { + tpl := `{{true | ternary "foo" "bar"}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + + tpl = `{{ternary "foo" "bar" true}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + + tpl = `{{false | ternary "foo" "bar"}}` + if err := runt(tpl, "bar"); err != nil { + t.Error(err) + } + + tpl = `{{ternary "foo" "bar" false}}` + if err := runt(tpl, "bar"); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/dict.go b/util/sprig/dict.go new file mode 100644 index 00000000..fd2dd711 --- /dev/null +++ b/util/sprig/dict.go @@ -0,0 +1,118 @@ +package sprig + +func get(d map[string]interface{}, key string) interface{} { + if val, ok := d[key]; ok { + return val + } + return "" +} + +func set(d map[string]interface{}, key string, value interface{}) map[string]interface{} { + d[key] = value + return d +} + +func unset(d map[string]interface{}, key string) map[string]interface{} { + delete(d, key) + return d +} + +func hasKey(d map[string]interface{}, key string) bool { + _, ok := d[key] + return ok +} + +func pluck(key string, d ...map[string]interface{}) []interface{} { + res := []interface{}{} + for _, dict := range d { + if val, ok := dict[key]; ok { + res = append(res, val) + } + } + return res +} + +func keys(dicts ...map[string]interface{}) []string { + k := []string{} + for _, dict := range dicts { + for key := range dict { + k = append(k, key) + } + } + return k +} + +func pick(dict map[string]interface{}, keys ...string) map[string]interface{} { + res := map[string]interface{}{} + for _, k := range keys { + if v, ok := dict[k]; ok { + res[k] = v + } + } + return res +} + +func omit(dict map[string]interface{}, keys ...string) map[string]interface{} { + res := map[string]interface{}{} + + omit := make(map[string]bool, len(keys)) + for _, k := range keys { + omit[k] = true + } + + for k, v := range dict { + if _, ok := omit[k]; !ok { + res[k] = v + } + } + return res +} + +func dict(v ...interface{}) map[string]interface{} { + dict := map[string]interface{}{} + lenv := len(v) + for i := 0; i < lenv; i += 2 { + key := strval(v[i]) + if i+1 >= lenv { + dict[key] = "" + continue + } + dict[key] = v[i+1] + } + return dict +} + +func values(dict map[string]interface{}) []interface{} { + values := []interface{}{} + for _, value := range dict { + values = append(values, value) + } + + return values +} + +func dig(ps ...interface{}) (interface{}, error) { + if len(ps) < 3 { + panic("dig needs at least three arguments") + } + dict := ps[len(ps)-1].(map[string]interface{}) + def := ps[len(ps)-2] + ks := make([]string, len(ps)-2) + for i := 0; i < len(ks); i++ { + ks[i] = ps[i].(string) + } + + return digFromDict(dict, def, ks) +} + +func digFromDict(dict map[string]interface{}, d interface{}, ks []string) (interface{}, error) { + k, ns := ks[0], ks[1:] + step, has := dict[k] + if !has { + return d, nil + } + if len(ns) == 0 { + return step, nil + } + return digFromDict(step.(map[string]interface{}), d, ns) +} diff --git a/util/sprig/dict_test.go b/util/sprig/dict_test.go new file mode 100644 index 00000000..0b293140 --- /dev/null +++ b/util/sprig/dict_test.go @@ -0,0 +1,166 @@ +package sprig + +import ( + "strings" + "testing" +) + +func TestDict(t *testing.T) { + tpl := `{{$d := dict 1 2 "three" "four" 5}}{{range $k, $v := $d}}{{$k}}{{$v}}{{end}}` + out, err := runRaw(tpl, nil) + if err != nil { + t.Error(err) + } + if len(out) != 12 { + t.Errorf("Expected length 12, got %d", len(out)) + } + // dict does not guarantee ordering because it is backed by a map. + if !strings.Contains(out, "12") { + t.Error("Expected grouping 12") + } + if !strings.Contains(out, "threefour") { + t.Error("Expected grouping threefour") + } + if !strings.Contains(out, "5") { + t.Error("Expected 5") + } + tpl = `{{$t := dict "I" "shot" "the" "albatross"}}{{$t.the}} {{$t.I}}` + if err := runt(tpl, "albatross shot"); err != nil { + t.Error(err) + } +} + +func TestUnset(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- $_ := unset $d "two" -}} + {{- range $k, $v := $d}}{{$k}}{{$v}}{{- end -}} + ` + + expect := "one1" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} +func TestHasKey(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- if hasKey $d "one" -}}1{{- end -}} + ` + + expect := "1" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestPluck(t *testing.T) { + tpl := ` + {{- $d := dict "one" 1 "two" 222222 -}} + {{- $d2 := dict "one" 1 "two" 33333 -}} + {{- $d3 := dict "one" 1 -}} + {{- $d4 := dict "one" 1 "two" 4444 -}} + {{- pluck "two" $d $d2 $d3 $d4 -}} + ` + + expect := "[222222 33333 4444]" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestKeys(t *testing.T) { + tests := map[string]string{ + `{{ dict "foo" 1 "bar" 2 | keys | sortAlpha }}`: "[bar foo]", + `{{ dict | keys }}`: "[]", + `{{ keys (dict "foo" 1) (dict "bar" 2) (dict "bar" 3) | uniq | sortAlpha }}`: "[bar foo]", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestPick(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" | len -}}`: "1", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" -}}`: "map[two:222222]", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" | len -}}`: "2", + `{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" "three" | len -}}`: "2", + `{{- $d := dict }}{{ pick $d "two" | len -}}`: "0", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} +func TestOmit(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" | len -}}`: "1", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" -}}`: "map[two:222222]", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" "two" | len -}}`: "0", + `{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "two" "three" | len -}}`: "1", + `{{- $d := dict }}{{ omit $d "two" | len -}}`: "0", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestGet(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "one" 1 }}{{ get $d "one" -}}`: "1", + `{{- $d := dict "one" 1 "two" "2" }}{{ get $d "two" -}}`: "2", + `{{- $d := dict }}{{ get $d "two" -}}`: "", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestSet(t *testing.T) { + tpl := `{{- $d := dict "one" 1 "two" 222222 -}} + {{- $_ := set $d "two" 2 -}} + {{- $_ := set $d "three" 3 -}} + {{- if hasKey $d "one" -}}{{$d.one}}{{- end -}} + {{- if hasKey $d "two" -}}{{$d.two}}{{- end -}} + {{- if hasKey $d "three" -}}{{$d.three}}{{- end -}} + ` + + expect := "123" + if err := runt(tpl, expect); err != nil { + t.Error(err) + } +} + +func TestValues(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "a" 1 "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "1,2", + `{{- $d := dict "a" "first" "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "2,first", + } + + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} + +func TestDig(t *testing.T) { + tests := map[string]string{ + `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "c" "" $d }}`: "1", + `{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "z" "2" $d }}`: "2", + `{{ dict "a" 1 | dig "a" "" }}`: "1", + `{{ dict "a" 1 | dig "z" "2" }}`: "2", + } + + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} diff --git a/util/sprig/doc.go b/util/sprig/doc.go new file mode 100644 index 00000000..91031d6d --- /dev/null +++ b/util/sprig/doc.go @@ -0,0 +1,19 @@ +/* +Package sprig provides template functions for Go. + +This package contains a number of utility functions for working with data +inside of Go `html/template` and `text/template` files. + +To add these functions, use the `template.Funcs()` method: + + t := template.New("foo").Funcs(sprig.FuncMap()) + +Note that you should add the function map before you parse any template files. + + In several cases, Sprig reverses the order of arguments from the way they + appear in the standard library. This is to make it easier to pipe + arguments into functions. + +See http://masterminds.github.io/sprig/ for more detailed documentation on each of the available functions. +*/ +package sprig diff --git a/util/sprig/example_test.go b/util/sprig/example_test.go new file mode 100644 index 00000000..2d7696bf --- /dev/null +++ b/util/sprig/example_test.go @@ -0,0 +1,25 @@ +package sprig + +import ( + "fmt" + "os" + "text/template" +) + +func Example() { + // Set up variables and template. + vars := map[string]interface{}{"Name": " John Jacob Jingleheimer Schmidt "} + tpl := `Hello {{.Name | trim | lower}}` + + // Get the Sprig function map. + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + + err := t.Execute(os.Stdout, vars) + if err != nil { + fmt.Printf("Error during template execution: %s", err) + return + } + // Output: + // Hello john jacob jingleheimer schmidt +} diff --git a/util/sprig/flow_control_test.go b/util/sprig/flow_control_test.go new file mode 100644 index 00000000..d4e5ebf0 --- /dev/null +++ b/util/sprig/flow_control_test.go @@ -0,0 +1,16 @@ +package sprig + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFail(t *testing.T) { + const msg = "This is an error!" + tpl := fmt.Sprintf(`{{fail "%s"}}`, msg) + _, err := runRaw(tpl, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), msg) +} diff --git a/util/sprig/functions.go b/util/sprig/functions.go new file mode 100644 index 00000000..8549e99c --- /dev/null +++ b/util/sprig/functions.go @@ -0,0 +1,302 @@ +package sprig + +import ( + "errors" + "html/template" + "math/rand" + "path" + "path/filepath" + "reflect" + "strconv" + "strings" + ttemplate "text/template" + "time" +) + +// FuncMap produces the function map. +// +// Use this to pass the functions into the template engine: +// +// tpl := template.New("foo").Funcs(sprig.FuncMap())) +func FuncMap() template.FuncMap { + return HTMLFuncMap() +} + +// HermeticTxtFuncMap returns a 'text/template'.FuncMap with only repeatable functions. +func HermeticTxtFuncMap() ttemplate.FuncMap { + r := TxtFuncMap() + for _, name := range nonhermeticFunctions { + delete(r, name) + } + return r +} + +// HermeticHTMLFuncMap returns an 'html/template'.Funcmap with only repeatable functions. +func HermeticHTMLFuncMap() template.FuncMap { + r := HTMLFuncMap() + for _, name := range nonhermeticFunctions { + delete(r, name) + } + return r +} + +// TxtFuncMap returns a 'text/template'.FuncMap +func TxtFuncMap() ttemplate.FuncMap { + return ttemplate.FuncMap(GenericFuncMap()) +} + +// HTMLFuncMap returns an 'html/template'.Funcmap +func HTMLFuncMap() template.FuncMap { + return template.FuncMap(GenericFuncMap()) +} + +// GenericFuncMap returns a copy of the basic function map as a map[string]interface{}. +func GenericFuncMap() map[string]interface{} { + gfm := make(map[string]interface{}, len(genericMap)) + for k, v := range genericMap { + gfm[k] = v + } + return gfm +} + +// These functions are not guaranteed to evaluate to the same result for given input, because they +// refer to the environment or global state. +var nonhermeticFunctions = []string{ + // Date functions + "date", + "date_in_zone", + "date_modify", + "now", + "htmlDate", + "htmlDateInZone", + "dateInZone", + "dateModify", + + // Strings + "randAlphaNum", + "randAlpha", + "randAscii", + "randNumeric", + "randBytes", + "uuidv4", +} + +var genericMap = map[string]interface{}{ + "hello": func() string { return "Hello!" }, + + // Date functions + "ago": dateAgo, + "date": date, + "date_in_zone": dateInZone, + "date_modify": dateModify, + "dateInZone": dateInZone, + "dateModify": dateModify, + "duration": duration, + "durationRound": durationRound, + "htmlDate": htmlDate, + "htmlDateInZone": htmlDateInZone, + "must_date_modify": mustDateModify, + "mustDateModify": mustDateModify, + "mustToDate": mustToDate, + "now": time.Now, + "toDate": toDate, + "unixEpoch": unixEpoch, + + // Strings + "trunc": trunc, + "trim": strings.TrimSpace, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": strings.Title, + "substr": substring, + // Switch order so that "foo" | repeat 5 + "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, + // Deprecated: Use trimAll. + "trimall": func(a, b string) string { return strings.Trim(b, a) }, + // Switch order so that "$foo" | trimall "$" + "trimAll": func(a, b string) string { return strings.Trim(b, a) }, + "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, + "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, + // Switch order so that "foobar" | contains "foo" + "contains": func(substr string, str string) bool { return strings.Contains(str, substr) }, + "hasPrefix": func(substr string, str string) bool { return strings.HasPrefix(str, substr) }, + "hasSuffix": func(substr string, str string) bool { return strings.HasSuffix(str, substr) }, + "quote": quote, + "squote": squote, + "cat": cat, + "indent": indent, + "nindent": nindent, + "replace": replace, + "plural": plural, + "sha1sum": sha1sum, + "sha256sum": sha256sum, + "sha512sum": sha512sum, + "adler32sum": adler32sum, + "toString": strval, + + // Wrap Atoi to stop errors. + "atoi": func(a string) int { i, _ := strconv.Atoi(a); return i }, + "seq": seq, + "toDecimal": toDecimal, + + //"gt": func(a, b int) bool {return a > b}, + //"gte": func(a, b int) bool {return a >= b}, + //"lt": func(a, b int) bool {return a < b}, + //"lte": func(a, b int) bool {return a <= b}, + + // split "/" foo/bar returns map[int]string{0: foo, 1: bar} + "split": split, + "splitList": func(sep, orig string) []string { return strings.Split(orig, sep) }, + // splitn "/" foo/bar/fuu returns map[int]string{0: foo, 1: bar/fuu} + "splitn": splitn, + "toStrings": strslice, + + "until": until, + "untilStep": untilStep, + + // VERY basic arithmetic. + "add1": func(i interface{}) int64 { return toInt64(i) + 1 }, + "add": func(i ...interface{}) int64 { + var a int64 = 0 + for _, b := range i { + a += toInt64(b) + } + return a + }, + "sub": func(a, b interface{}) int64 { return toInt64(a) - toInt64(b) }, + "div": func(a, b interface{}) int64 { return toInt64(a) / toInt64(b) }, + "mod": func(a, b interface{}) int64 { return toInt64(a) % toInt64(b) }, + "mul": func(a interface{}, v ...interface{}) int64 { + val := toInt64(a) + for _, b := range v { + val = val * toInt64(b) + } + return val + }, + "randInt": func(min, max int) int { return rand.Intn(max-min) + min }, + "biggest": max, + "max": max, + "min": min, + "maxf": maxf, + "minf": minf, + "ceil": ceil, + "floor": floor, + "round": round, + + // string slices. Note that we reverse the order b/c that's better + // for template processing. + "join": join, + "sortAlpha": sortAlpha, + + // Defaults + "default": dfault, + "empty": empty, + "coalesce": coalesce, + "all": all, + "any": any, + "compact": compact, + "mustCompact": mustCompact, + "fromJSON": fromJSON, + "toJSON": toJSON, + "toPrettyJSON": toPrettyJSON, + "toRawJSON": toRawJSON, + "mustFromJSON": mustFromJSON, + "mustToJSON": mustToJSON, + "mustToPrettyJSON": mustToPrettyJSON, + "mustToRawJSON": mustToRawJSON, + "ternary": ternary, + + // Reflection + "typeOf": typeOf, + "typeIs": typeIs, + "typeIsLike": typeIsLike, + "kindOf": kindOf, + "kindIs": kindIs, + "deepEqual": reflect.DeepEqual, + + // Paths: + "base": path.Base, + "dir": path.Dir, + "clean": path.Clean, + "ext": path.Ext, + "isAbs": path.IsAbs, + + // Filepaths: + "osBase": filepath.Base, + "osClean": filepath.Clean, + "osDir": filepath.Dir, + "osExt": filepath.Ext, + "osIsAbs": filepath.IsAbs, + + // Encoding: + "b64enc": base64encode, + "b64dec": base64decode, + "b32enc": base32encode, + "b32dec": base32decode, + + // Data Structures: + "tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable. + "list": list, + "dict": dict, + "get": get, + "set": set, + "unset": unset, + "hasKey": hasKey, + "pluck": pluck, + "keys": keys, + "pick": pick, + "omit": omit, + "values": values, + + "append": push, "push": push, + "mustAppend": mustPush, "mustPush": mustPush, + "prepend": prepend, + "mustPrepend": mustPrepend, + "first": first, + "mustFirst": mustFirst, + "rest": rest, + "mustRest": mustRest, + "last": last, + "mustLast": mustLast, + "initial": initial, + "mustInitial": mustInitial, + "reverse": reverse, + "mustReverse": mustReverse, + "uniq": uniq, + "mustUniq": mustUniq, + "without": without, + "mustWithout": mustWithout, + "has": has, + "mustHas": mustHas, + "slice": slice, + "mustSlice": mustSlice, + "concat": concat, + "dig": dig, + "chunk": chunk, + "mustChunk": mustChunk, + + // UUIDs: + "uuidv4": uuidv4, + + // Flow Control: + "fail": func(msg string) (string, error) { return "", errors.New(msg) }, + + // Regex + "regexMatch": regexMatch, + "mustRegexMatch": mustRegexMatch, + "regexFindAll": regexFindAll, + "mustRegexFindAll": mustRegexFindAll, + "regexFind": regexFind, + "mustRegexFind": mustRegexFind, + "regexReplaceAll": regexReplaceAll, + "mustRegexReplaceAll": mustRegexReplaceAll, + "regexReplaceAllLiteral": regexReplaceAllLiteral, + "mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral, + "regexSplit": regexSplit, + "mustRegexSplit": mustRegexSplit, + "regexQuoteMeta": regexQuoteMeta, + + // URLs: + "urlParse": urlParse, + "urlJoin": urlJoin, +} diff --git a/util/sprig/functions_linux_test.go b/util/sprig/functions_linux_test.go new file mode 100644 index 00000000..cfbf253a --- /dev/null +++ b/util/sprig/functions_linux_test.go @@ -0,0 +1,28 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOsBase(t *testing.T) { + assert.NoError(t, runt(`{{ osBase "foo/bar" }}`, "bar")) +} + +func TestOsDir(t *testing.T) { + assert.NoError(t, runt(`{{ osDir "foo/bar/baz" }}`, "foo/bar")) +} + +func TestOsIsAbs(t *testing.T) { + assert.NoError(t, runt(`{{ osIsAbs "/foo" }}`, "true")) + assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) +} + +func TestOsClean(t *testing.T) { + assert.NoError(t, runt(`{{ osClean "/foo/../foo/../bar" }}`, "/bar")) +} + +func TestOsExt(t *testing.T) { + assert.NoError(t, runt(`{{ osExt "/foo/bar/baz.txt" }}`, ".txt")) +} diff --git a/util/sprig/functions_test.go b/util/sprig/functions_test.go new file mode 100644 index 00000000..b7bc01f4 --- /dev/null +++ b/util/sprig/functions_test.go @@ -0,0 +1,70 @@ +package sprig + +import ( + "bytes" + "fmt" + "testing" + "text/template" + + "github.com/stretchr/testify/assert" +) + +func TestBase(t *testing.T) { + assert.NoError(t, runt(`{{ base "foo/bar" }}`, "bar")) +} + +func TestDir(t *testing.T) { + assert.NoError(t, runt(`{{ dir "foo/bar/baz" }}`, "foo/bar")) +} + +func TestIsAbs(t *testing.T) { + assert.NoError(t, runt(`{{ isAbs "/foo" }}`, "true")) + assert.NoError(t, runt(`{{ isAbs "foo" }}`, "false")) +} + +func TestClean(t *testing.T) { + assert.NoError(t, runt(`{{ clean "/foo/../foo/../bar" }}`, "/bar")) +} + +func TestExt(t *testing.T) { + assert.NoError(t, runt(`{{ ext "/foo/bar/baz.txt" }}`, ".txt")) +} + +func TestRegex(t *testing.T) { + assert.NoError(t, runt(`{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3")) + assert.NoError(t, runt(`{{ regexQuoteMeta "pretzel" }}`, "pretzel")) +} + +// runt runs a template and checks that the output exactly matches the expected string. +func runt(tpl, expect string) error { + return runtv(tpl, expect, map[string]string{}) +} + +// runtv takes a template, and expected return, and values for substitution. +// +// It runs the template and verifies that the output is an exact match. +func runtv(tpl, expect string, vars interface{}) error { + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + var b bytes.Buffer + err := t.Execute(&b, vars) + if err != nil { + return err + } + if expect != b.String() { + return fmt.Errorf("Expected '%s', got '%s'", expect, b.String()) + } + return nil +} + +// runRaw runs a template with the given variables and returns the result. +func runRaw(tpl string, vars interface{}) (string, error) { + fmap := TxtFuncMap() + t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) + var b bytes.Buffer + err := t.Execute(&b, vars) + if err != nil { + return "", err + } + return b.String(), nil +} diff --git a/util/sprig/functions_windows_test.go b/util/sprig/functions_windows_test.go new file mode 100644 index 00000000..9d8bd0e5 --- /dev/null +++ b/util/sprig/functions_windows_test.go @@ -0,0 +1,28 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOsBase(t *testing.T) { + assert.NoError(t, runt(`{{ osBase "C:\\foo\\bar" }}`, "bar")) +} + +func TestOsDir(t *testing.T) { + assert.NoError(t, runt(`{{ osDir "C:\\foo\\bar\\baz" }}`, "C:\\foo\\bar")) +} + +func TestOsIsAbs(t *testing.T) { + assert.NoError(t, runt(`{{ osIsAbs "C:\\foo" }}`, "true")) + assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) +} + +func TestOsClean(t *testing.T) { + assert.NoError(t, runt(`{{ osClean "C:\\foo\\..\\foo\\..\\bar" }}`, "C:\\bar")) +} + +func TestOsExt(t *testing.T) { + assert.NoError(t, runt(`{{ osExt "C:\\foo\\bar\\baz.txt" }}`, ".txt")) +} diff --git a/util/sprig/list.go b/util/sprig/list.go new file mode 100644 index 00000000..ca0fbb78 --- /dev/null +++ b/util/sprig/list.go @@ -0,0 +1,464 @@ +package sprig + +import ( + "fmt" + "math" + "reflect" + "sort" +) + +// Reflection is used in these functions so that slices and arrays of strings, +// ints, and other types not implementing []interface{} can be worked with. +// For example, this is useful if you need to work on the output of regexs. + +func list(v ...interface{}) []interface{} { + return v +} + +func push(list interface{}, v interface{}) []interface{} { + l, err := mustPush(list, v) + if err != nil { + panic(err) + } + + return l +} + +func mustPush(list interface{}, v interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + nl := make([]interface{}, l) + for i := 0; i < l; i++ { + nl[i] = l2.Index(i).Interface() + } + + return append(nl, v), nil + + default: + return nil, fmt.Errorf("Cannot push on type %s", tp) + } +} + +func prepend(list interface{}, v interface{}) []interface{} { + l, err := mustPrepend(list, v) + if err != nil { + panic(err) + } + + return l +} + +func mustPrepend(list interface{}, v interface{}) ([]interface{}, error) { + //return append([]interface{}{v}, list...) + + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + nl := make([]interface{}, l) + for i := 0; i < l; i++ { + nl[i] = l2.Index(i).Interface() + } + + return append([]interface{}{v}, nl...), nil + + default: + return nil, fmt.Errorf("Cannot prepend on type %s", tp) + } +} + +func chunk(size int, list interface{}) [][]interface{} { + l, err := mustChunk(size, list) + if err != nil { + panic(err) + } + + return l +} + +func mustChunk(size int, list interface{}) ([][]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + + cs := int(math.Floor(float64(l-1)/float64(size)) + 1) + nl := make([][]interface{}, cs) + + for i := 0; i < cs; i++ { + clen := size + if i == cs-1 { + clen = int(math.Floor(math.Mod(float64(l), float64(size)))) + if clen == 0 { + clen = size + } + } + + nl[i] = make([]interface{}, clen) + + for j := 0; j < clen; j++ { + ix := i*size + j + nl[i][j] = l2.Index(ix).Interface() + } + } + + return nl, nil + + default: + return nil, fmt.Errorf("Cannot chunk type %s", tp) + } +} + +func last(list interface{}) interface{} { + l, err := mustLast(list) + if err != nil { + panic(err) + } + + return l +} + +func mustLast(list interface{}) (interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + return l2.Index(l - 1).Interface(), nil + default: + return nil, fmt.Errorf("Cannot find last on type %s", tp) + } +} + +func first(list interface{}) interface{} { + l, err := mustFirst(list) + if err != nil { + panic(err) + } + + return l +} + +func mustFirst(list interface{}) (interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + return l2.Index(0).Interface(), nil + default: + return nil, fmt.Errorf("Cannot find first on type %s", tp) + } +} + +func rest(list interface{}) []interface{} { + l, err := mustRest(list) + if err != nil { + panic(err) + } + + return l +} + +func mustRest(list interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + nl := make([]interface{}, l-1) + for i := 1; i < l; i++ { + nl[i-1] = l2.Index(i).Interface() + } + + return nl, nil + default: + return nil, fmt.Errorf("Cannot find rest on type %s", tp) + } +} + +func initial(list interface{}) []interface{} { + l, err := mustInitial(list) + if err != nil { + panic(err) + } + + return l +} + +func mustInitial(list interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + nl := make([]interface{}, l-1) + for i := 0; i < l-1; i++ { + nl[i] = l2.Index(i).Interface() + } + + return nl, nil + default: + return nil, fmt.Errorf("Cannot find initial on type %s", tp) + } +} + +func sortAlpha(list interface{}) []string { + k := reflect.Indirect(reflect.ValueOf(list)).Kind() + switch k { + case reflect.Slice, reflect.Array: + a := strslice(list) + s := sort.StringSlice(a) + s.Sort() + return s + } + return []string{strval(list)} +} + +func reverse(v interface{}) []interface{} { + l, err := mustReverse(v) + if err != nil { + panic(err) + } + + return l +} + +func mustReverse(v interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(v).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(v) + + l := l2.Len() + // We do not sort in place because the incoming array should not be altered. + nl := make([]interface{}, l) + for i := 0; i < l; i++ { + nl[l-i-1] = l2.Index(i).Interface() + } + + return nl, nil + default: + return nil, fmt.Errorf("Cannot find reverse on type %s", tp) + } +} + +func compact(list interface{}) []interface{} { + l, err := mustCompact(list) + if err != nil { + panic(err) + } + + return l +} + +func mustCompact(list interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + nl := []interface{}{} + var item interface{} + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !empty(item) { + nl = append(nl, item) + } + } + + return nl, nil + default: + return nil, fmt.Errorf("Cannot compact on type %s", tp) + } +} + +func uniq(list interface{}) []interface{} { + l, err := mustUniq(list) + if err != nil { + panic(err) + } + + return l +} + +func mustUniq(list interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + dest := []interface{}{} + var item interface{} + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !inList(dest, item) { + dest = append(dest, item) + } + } + + return dest, nil + default: + return nil, fmt.Errorf("Cannot find uniq on type %s", tp) + } +} + +func inList(haystack []interface{}, needle interface{}) bool { + for _, h := range haystack { + if reflect.DeepEqual(needle, h) { + return true + } + } + return false +} + +func without(list interface{}, omit ...interface{}) []interface{} { + l, err := mustWithout(list, omit...) + if err != nil { + panic(err) + } + + return l +} + +func mustWithout(list interface{}, omit ...interface{}) ([]interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + res := []interface{}{} + var item interface{} + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if !inList(omit, item) { + res = append(res, item) + } + } + + return res, nil + default: + return nil, fmt.Errorf("Cannot find without on type %s", tp) + } +} + +func has(needle interface{}, haystack interface{}) bool { + l, err := mustHas(needle, haystack) + if err != nil { + panic(err) + } + + return l +} + +func mustHas(needle interface{}, haystack interface{}) (bool, error) { + if haystack == nil { + return false, nil + } + tp := reflect.TypeOf(haystack).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(haystack) + var item interface{} + l := l2.Len() + for i := 0; i < l; i++ { + item = l2.Index(i).Interface() + if reflect.DeepEqual(needle, item) { + return true, nil + } + } + + return false, nil + default: + return false, fmt.Errorf("Cannot find has on type %s", tp) + } +} + +// $list := [1, 2, 3, 4, 5] +// slice $list -> list[0:5] = list[:] +// slice $list 0 3 -> list[0:3] = list[:3] +// slice $list 3 5 -> list[3:5] +// slice $list 3 -> list[3:5] = list[3:] +func slice(list interface{}, indices ...interface{}) interface{} { + l, err := mustSlice(list, indices...) + if err != nil { + panic(err) + } + + return l +} + +func mustSlice(list interface{}, indices ...interface{}) (interface{}, error) { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + + l := l2.Len() + if l == 0 { + return nil, nil + } + + var start, end int + if len(indices) > 0 { + start = toInt(indices[0]) + } + if len(indices) < 2 { + end = l + } else { + end = toInt(indices[1]) + } + + return l2.Slice(start, end).Interface(), nil + default: + return nil, fmt.Errorf("list should be type of slice or array but %s", tp) + } +} + +func concat(lists ...interface{}) interface{} { + var res []interface{} + for _, list := range lists { + tp := reflect.TypeOf(list).Kind() + switch tp { + case reflect.Slice, reflect.Array: + l2 := reflect.ValueOf(list) + for i := 0; i < l2.Len(); i++ { + res = append(res, l2.Index(i).Interface()) + } + default: + panic(fmt.Sprintf("Cannot concat type %s as list", tp)) + } + } + return res +} diff --git a/util/sprig/list_test.go b/util/sprig/list_test.go new file mode 100644 index 00000000..ec4c4c14 --- /dev/null +++ b/util/sprig/list_test.go @@ -0,0 +1,364 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTuple(t *testing.T) { + tpl := `{{$t := tuple 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` + if err := runt(tpl, "foo1a"); err != nil { + t.Error(err) + } +} + +func TestList(t *testing.T) { + tpl := `{{$t := list 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}` + if err := runt(tpl, "foo1a"); err != nil { + t.Error(err) + } +} + +func TestPush(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ append $t 4 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ append $t 5 | join "-" }}`: "1-2-3-4-5", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ append $t "qux" | join "-" }}`: "foo-bar-baz-qux", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustPush(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ mustAppend $t 4 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ mustAppend $t 5 | join "-" }}`: "1-2-3-4-5", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPush $t "qux" | join "-" }}`: "foo-bar-baz-qux", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestChunk(t *testing.T) { + tests := map[string]string{ + `{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`: "3", + `{{ tuple | chunk 3 | len }}`: "0", + `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", + `{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", + `{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustChunk(t *testing.T) { + tests := map[string]string{ + `{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`: "3", + `{{ tuple | mustChunk 3 | len }}`: "0", + `{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|", + `{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|", + `{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestPrepend(t *testing.T) { + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ prepend $t 0 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ prepend $t 0 | join "-" }}`: "0-1-2-3-4", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ prepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustPrepend(t *testing.T) { + tests := map[string]string{ + `{{ $t := tuple 1 2 3 }}{{ mustPrepend $t 0 | len }}`: "4", + `{{ $t := tuple 1 2 3 4 }}{{ mustPrepend $t 0 | join "-" }}`: "0-1-2-3-4", + `{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPrepend $t "qux" | join "-" }}`: "qux-foo-bar-baz", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestFirst(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | first }}`: "1", + `{{ list | first }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | first }}`: "foo", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustFirst(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustFirst }}`: "1", + `{{ list | mustFirst }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | mustFirst }}`: "foo", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestLast(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | last }}`: "3", + `{{ list | last }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | last }}`: "bar", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustLast(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustLast }}`: "3", + `{{ list | mustLast }}`: "", + `{{ regexSplit "/src/" "foo/src/bar" -1 | mustLast }}`: "bar", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestInitial(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | initial | len }}`: "2", + `{{ list 1 2 3 | initial | last }}`: "2", + `{{ list 1 2 3 | initial | first }}`: "1", + `{{ list | initial }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | initial }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustInitial(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustInitial | len }}`: "2", + `{{ list 1 2 3 | mustInitial | last }}`: "2", + `{{ list 1 2 3 | mustInitial | first }}`: "1", + `{{ list | mustInitial }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustInitial }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestRest(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | rest | len }}`: "2", + `{{ list 1 2 3 | rest | last }}`: "3", + `{{ list 1 2 3 | rest | first }}`: "2", + `{{ list | rest }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | rest }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustRest(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustRest | len }}`: "2", + `{{ list 1 2 3 | mustRest | last }}`: "3", + `{{ list 1 2 3 | mustRest | first }}`: "2", + `{{ list | mustRest }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustRest }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestReverse(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | reverse | first }}`: "3", + `{{ list 1 2 3 | reverse | rest | first }}`: "2", + `{{ list 1 2 3 | reverse | last }}`: "1", + `{{ list 1 2 3 4 | reverse }}`: "[4 3 2 1]", + `{{ list 1 | reverse }}`: "[1]", + `{{ list | reverse }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | reverse }}`: "[baz bar foo]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustReverse(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustReverse | first }}`: "3", + `{{ list 1 2 3 | mustReverse | rest | first }}`: "2", + `{{ list 1 2 3 | mustReverse | last }}`: "1", + `{{ list 1 2 3 4 | mustReverse }}`: "[4 3 2 1]", + `{{ list 1 | mustReverse }}`: "[1]", + `{{ list | mustReverse }}`: "[]", + `{{ regexSplit "/" "foo/bar/baz" -1 | mustReverse }}`: "[baz bar foo]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestCompact(t *testing.T) { + tests := map[string]string{ + `{{ list 1 0 "" "hello" | compact }}`: `[1 hello]`, + `{{ list "" "" | compact }}`: `[]`, + `{{ list | compact }}`: `[]`, + `{{ regexSplit "/" "foo//bar" -1 | compact }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustCompact(t *testing.T) { + tests := map[string]string{ + `{{ list 1 0 "" "hello" | mustCompact }}`: `[1 hello]`, + `{{ list "" "" | mustCompact }}`: `[]`, + `{{ list | mustCompact }}`: `[]`, + `{{ regexSplit "/" "foo//bar" -1 | mustCompact }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestUniq(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 4 | uniq }}`: `[1 2 3 4]`, + `{{ list "a" "b" "c" "d" | uniq }}`: `[a b c d]`, + `{{ list 1 1 1 1 2 2 2 2 | uniq }}`: `[1 2]`, + `{{ list "foo" 1 1 1 1 "foo" "foo" | uniq }}`: `[foo 1]`, + `{{ list | uniq }}`: `[]`, + `{{ regexSplit "/" "foo/foo/bar" -1 | uniq }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustUniq(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 4 | mustUniq }}`: `[1 2 3 4]`, + `{{ list "a" "b" "c" "d" | mustUniq }}`: `[a b c d]`, + `{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`: `[1 2]`, + `{{ list "foo" 1 1 1 1 "foo" "foo" | mustUniq }}`: `[foo 1]`, + `{{ list | mustUniq }}`: `[]`, + `{{ regexSplit "/" "foo/foo/bar" -1 | mustUniq }}`: "[foo bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestWithout(t *testing.T) { + tests := map[string]string{ + `{{ without (list 1 2 3 4) 1 }}`: `[2 3 4]`, + `{{ without (list "a" "b" "c" "d") "a" }}`: `[b c d]`, + `{{ without (list 1 1 1 1 2) 1 }}`: `[2]`, + `{{ without (list) 1 }}`: `[]`, + `{{ without (list 1 2 3) }}`: `[1 2 3]`, + `{{ without list }}`: `[]`, + `{{ without (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustWithout(t *testing.T) { + tests := map[string]string{ + `{{ mustWithout (list 1 2 3 4) 1 }}`: `[2 3 4]`, + `{{ mustWithout (list "a" "b" "c" "d") "a" }}`: `[b c d]`, + `{{ mustWithout (list 1 1 1 1 2) 1 }}`: `[2]`, + `{{ mustWithout (list) 1 }}`: `[]`, + `{{ mustWithout (list 1 2 3) }}`: `[1 2 3]`, + `{{ mustWithout list }}`: `[]`, + `{{ mustWithout (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestHas(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | has 1 }}`: `true`, + `{{ list 1 2 3 | has 4 }}`: `false`, + `{{ regexSplit "/" "foo/bar/baz" -1 | has "bar" }}`: `true`, + `{{ has "bar" nil }}`: `false`, + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustHas(t *testing.T) { + tests := map[string]string{ + `{{ list 1 2 3 | mustHas 1 }}`: `true`, + `{{ list 1 2 3 | mustHas 4 }}`: `false`, + `{{ regexSplit "/" "foo/bar/baz" -1 | mustHas "bar" }}`: `true`, + `{{ mustHas "bar" nil }}`: `false`, + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestSlice(t *testing.T) { + tests := map[string]string{ + `{{ slice (list 1 2 3) }}`: "[1 2 3]", + `{{ slice (list 1 2 3) 0 1 }}`: "[1]", + `{{ slice (list 1 2 3) 1 3 }}`: "[2 3]", + `{{ slice (list 1 2 3) 1 }}`: "[2 3]", + `{{ slice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestMustSlice(t *testing.T) { + tests := map[string]string{ + `{{ mustSlice (list 1 2 3) }}`: "[1 2 3]", + `{{ mustSlice (list 1 2 3) 0 1 }}`: "[1]", + `{{ mustSlice (list 1 2 3) 1 3 }}`: "[2 3]", + `{{ mustSlice (list 1 2 3) 1 }}`: "[2 3]", + `{{ mustSlice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} + +func TestConcat(t *testing.T) { + tests := map[string]string{ + `{{ concat (list 1 2 3) }}`: "[1 2 3]", + `{{ concat (list 1 2 3) (list 4 5) }}`: "[1 2 3 4 5]", + `{{ concat (list 1 2 3) (list 4 5) (list) }}`: "[1 2 3 4 5]", + `{{ concat (list 1 2 3) (list 4 5) (list nil) }}`: "[1 2 3 4 5 ]", + `{{ concat (list 1 2 3) (list 4 5) (list ( list "foo" ) ) }}`: "[1 2 3 4 5 [foo]]", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go new file mode 100644 index 00000000..0b23cd21 --- /dev/null +++ b/util/sprig/numeric.go @@ -0,0 +1,228 @@ +package sprig + +import ( + "fmt" + "math" + "reflect" + "strconv" + "strings" +) + +// toFloat64 converts 64-bit floats +func toFloat64(v interface{}) float64 { + if str, ok := v.(string); ok { + iv, err := strconv.ParseFloat(str, 64) + if err != nil { + return 0 + } + return iv + } + + val := reflect.Indirect(reflect.ValueOf(v)) + switch val.Kind() { + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + return float64(val.Int()) + case reflect.Uint8, reflect.Uint16, reflect.Uint32: + return float64(val.Uint()) + case reflect.Uint, reflect.Uint64: + return float64(val.Uint()) + case reflect.Float32, reflect.Float64: + return val.Float() + case reflect.Bool: + if val.Bool() { + return 1 + } + return 0 + default: + return 0 + } +} + +func toInt(v interface{}) int { + // It's not optimal. But I don't want duplicate toInt64 code. + return int(toInt64(v)) +} + +// toInt64 converts integer types to 64-bit integers +func toInt64(v interface{}) int64 { + if str, ok := v.(string); ok { + iv, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return 0 + } + return iv + } + + val := reflect.Indirect(reflect.ValueOf(v)) + switch val.Kind() { + case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: + return val.Int() + case reflect.Uint8, reflect.Uint16, reflect.Uint32: + return int64(val.Uint()) + case reflect.Uint, reflect.Uint64: + tv := val.Uint() + if tv <= math.MaxInt64 { + return int64(tv) + } + // TODO: What is the sensible thing to do here? + return math.MaxInt64 + case reflect.Float32, reflect.Float64: + return int64(val.Float()) + case reflect.Bool: + if val.Bool() { + return 1 + } + return 0 + default: + return 0 + } +} + +func max(a interface{}, i ...interface{}) int64 { + aa := toInt64(a) + for _, b := range i { + bb := toInt64(b) + if bb > aa { + aa = bb + } + } + return aa +} + +func maxf(a interface{}, i ...interface{}) float64 { + aa := toFloat64(a) + for _, b := range i { + bb := toFloat64(b) + aa = math.Max(aa, bb) + } + return aa +} + +func min(a interface{}, i ...interface{}) int64 { + aa := toInt64(a) + for _, b := range i { + bb := toInt64(b) + if bb < aa { + aa = bb + } + } + return aa +} + +func minf(a interface{}, i ...interface{}) float64 { + aa := toFloat64(a) + for _, b := range i { + bb := toFloat64(b) + aa = math.Min(aa, bb) + } + return aa +} + +func until(count int) []int { + step := 1 + if count < 0 { + step = -1 + } + return untilStep(0, count, step) +} + +func untilStep(start, stop, step int) []int { + v := []int{} + + if stop < start { + if step >= 0 { + return v + } + for i := start; i > stop; i += step { + v = append(v, i) + } + return v + } + + if step <= 0 { + return v + } + for i := start; i < stop; i += step { + v = append(v, i) + } + return v +} + +func floor(a interface{}) float64 { + aa := toFloat64(a) + return math.Floor(aa) +} + +func ceil(a interface{}) float64 { + aa := toFloat64(a) + return math.Ceil(aa) +} + +func round(a interface{}, p int, rOpt ...float64) float64 { + roundOn := .5 + if len(rOpt) > 0 { + roundOn = rOpt[0] + } + val := toFloat64(a) + places := toFloat64(p) + + var round float64 + pow := math.Pow(10, places) + digit := pow * val + _, div := math.Modf(digit) + if div >= roundOn { + round = math.Ceil(digit) + } else { + round = math.Floor(digit) + } + return round / pow +} + +// converts unix octal to decimal +func toDecimal(v interface{}) int64 { + result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) + if err != nil { + return 0 + } + return result +} + +func seq(params ...int) string { + increment := 1 + switch len(params) { + case 0: + return "" + case 1: + start := 1 + end := params[0] + if end < start { + increment = -1 + } + return intArrayToString(untilStep(start, end+increment, increment), " ") + case 3: + start := params[0] + end := params[2] + step := params[1] + if end < start { + increment = -1 + if step > 0 { + return "" + } + } + return intArrayToString(untilStep(start, end+increment, step), " ") + case 2: + start := params[0] + end := params[1] + step := 1 + if end < start { + step = -1 + } + return intArrayToString(untilStep(start, end+step, step), " ") + default: + return "" + } +} + +func intArrayToString(slice []int, delimeter string) string { + return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimeter), "[]") +} diff --git a/util/sprig/numeric_test.go b/util/sprig/numeric_test.go new file mode 100644 index 00000000..94e8a6d4 --- /dev/null +++ b/util/sprig/numeric_test.go @@ -0,0 +1,307 @@ +package sprig + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "strconv" + "testing" +) + +func TestUntil(t *testing.T) { + tests := map[string]string{ + `{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`: "0011223344", + `{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: "00 1-1 2-2 3-3 4-4 ", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} +func TestUntilStep(t *testing.T) { + tests := map[string]string{ + `{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`: "0011223344", + `{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`: "031425", + `{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: "00 1-2 2-4 3-6 4-8 ", + `{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`: "", + `{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`: "", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } + +} +func TestBiggest(t *testing.T) { + tpl := `{{ biggest 1 2 3 345 5 6 7}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } + + tpl = `{{ max 345}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} +func TestMaxf(t *testing.T) { + tpl := `{{ maxf 1 2 3 345.7 5 6 7}}` + if err := runt(tpl, `345.7`); err != nil { + t.Error(err) + } + + tpl = `{{ max 345 }}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} +func TestMin(t *testing.T) { + tpl := `{{ min 1 2 3 345 5 6 7}}` + if err := runt(tpl, `1`); err != nil { + t.Error(err) + } + + tpl = `{{ min 345}}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} + +func TestMinf(t *testing.T) { + tpl := `{{ minf 1.4 2 3 345.6 5 6 7}}` + if err := runt(tpl, `1.4`); err != nil { + t.Error(err) + } + + tpl = `{{ minf 345 }}` + if err := runt(tpl, `345`); err != nil { + t.Error(err) + } +} + +func TestToFloat64(t *testing.T) { + target := float64(102) + if target != toFloat64(int8(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int32(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int16(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(int64(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64("102") { + t.Errorf("Expected 102") + } + if 0 != toFloat64("frankie") { + t.Errorf("Expected 0") + } + if target != toFloat64(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toFloat64(uint64(102)) { + t.Errorf("Expected 102") + } + if 102.1234 != toFloat64(float64(102.1234)) { + t.Errorf("Expected 102.1234") + } + if 1 != toFloat64(true) { + t.Errorf("Expected 102") + } +} +func TestToInt64(t *testing.T) { + target := int64(102) + if target != toInt64(int8(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int32(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int16(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(int64(102)) { + t.Errorf("Expected 102") + } + if target != toInt64("102") { + t.Errorf("Expected 102") + } + if 0 != toInt64("frankie") { + t.Errorf("Expected 0") + } + if target != toInt64(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(uint64(102)) { + t.Errorf("Expected 102") + } + if target != toInt64(float64(102.1234)) { + t.Errorf("Expected 102") + } + if 1 != toInt64(true) { + t.Errorf("Expected 102") + } +} + +func TestToInt(t *testing.T) { + target := int(102) + if target != toInt(int8(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int32(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int16(102)) { + t.Errorf("Expected 102") + } + if target != toInt(int64(102)) { + t.Errorf("Expected 102") + } + if target != toInt("102") { + t.Errorf("Expected 102") + } + if 0 != toInt("frankie") { + t.Errorf("Expected 0") + } + if target != toInt(uint16(102)) { + t.Errorf("Expected 102") + } + if target != toInt(uint64(102)) { + t.Errorf("Expected 102") + } + if target != toInt(float64(102.1234)) { + t.Errorf("Expected 102") + } + if 1 != toInt(true) { + t.Errorf("Expected 102") + } +} + +func TestToDecimal(t *testing.T) { + tests := map[interface{}]int64{ + "777": 511, + 777: 511, + 770: 504, + 755: 493, + } + + for input, expectedResult := range tests { + result := toDecimal(input) + if result != expectedResult { + t.Errorf("Expected %v but got %v", expectedResult, result) + } + } +} + +func TestAdd1(t *testing.T) { + tpl := `{{ 3 | add1 }}` + if err := runt(tpl, `4`); err != nil { + t.Error(err) + } +} + +func TestAdd(t *testing.T) { + tpl := `{{ 3 | add 1 2}}` + if err := runt(tpl, `6`); err != nil { + t.Error(err) + } +} + +func TestDiv(t *testing.T) { + tpl := `{{ 4 | div 5 }}` + if err := runt(tpl, `1`); err != nil { + t.Error(err) + } +} + +func TestMul(t *testing.T) { + tpl := `{{ 1 | mul "2" 3 "4"}}` + if err := runt(tpl, `24`); err != nil { + t.Error(err) + } +} + +func TestSub(t *testing.T) { + tpl := `{{ 3 | sub 14 }}` + if err := runt(tpl, `11`); err != nil { + t.Error(err) + } +} + +func TestCeil(t *testing.T) { + assert.Equal(t, 123.0, ceil(123)) + assert.Equal(t, 123.0, ceil("123")) + assert.Equal(t, 124.0, ceil(123.01)) + assert.Equal(t, 124.0, ceil("123.01")) +} + +func TestFloor(t *testing.T) { + assert.Equal(t, 123.0, floor(123)) + assert.Equal(t, 123.0, floor("123")) + assert.Equal(t, 123.0, floor(123.9999)) + assert.Equal(t, 123.0, floor("123.9999")) +} + +func TestRound(t *testing.T) { + assert.Equal(t, 123.556, round(123.5555, 3)) + assert.Equal(t, 123.556, round("123.55555", 3)) + assert.Equal(t, 124.0, round(123.500001, 0)) + assert.Equal(t, 123.0, round(123.49999999, 0)) + assert.Equal(t, 123.23, round(123.2329999, 2, .3)) + assert.Equal(t, 123.24, round(123.233, 2, .3)) +} + +func TestRandomInt(t *testing.T) { + var tests = []struct { + min int + max int + }{ + {10, 11}, + {10, 13}, + {0, 1}, + {5, 50}, + } + for _, v := range tests { + x, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil) + r, err := strconv.Atoi(x) + assert.NoError(t, err) + assert.True(t, func(min, max, r int) bool { + return r >= v.min && r < v.max + }(v.min, v.max, r)) + } +} + +func TestSeq(t *testing.T) { + tests := map[string]string{ + `{{seq 0 1 3}}`: "0 1 2 3", + `{{seq 0 3 10}}`: "0 3 6 9", + `{{seq 3 3 2}}`: "", + `{{seq 3 -3 2}}`: "3", + `{{seq}}`: "", + `{{seq 0 4}}`: "0 1 2 3 4", + `{{seq 5}}`: "1 2 3 4 5", + `{{seq -5}}`: "1 0 -1 -2 -3 -4 -5", + `{{seq 0}}`: "1 0", + `{{seq 0 1 2 3}}`: "", + `{{seq 0 -4}}`: "0 -1 -2 -3 -4", + } + for tpl, expect := range tests { + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + } +} diff --git a/util/sprig/reflect.go b/util/sprig/reflect.go new file mode 100644 index 00000000..8a65c132 --- /dev/null +++ b/util/sprig/reflect.go @@ -0,0 +1,28 @@ +package sprig + +import ( + "fmt" + "reflect" +) + +// typeIs returns true if the src is the type named in target. +func typeIs(target string, src interface{}) bool { + return target == typeOf(src) +} + +func typeIsLike(target string, src interface{}) bool { + t := typeOf(src) + return target == t || "*"+target == t +} + +func typeOf(src interface{}) string { + return fmt.Sprintf("%T", src) +} + +func kindIs(target string, src interface{}) bool { + return target == kindOf(src) +} + +func kindOf(src interface{}) string { + return reflect.ValueOf(src).Kind().String() +} diff --git a/util/sprig/reflect_test.go b/util/sprig/reflect_test.go new file mode 100644 index 00000000..f102907e --- /dev/null +++ b/util/sprig/reflect_test.go @@ -0,0 +1,73 @@ +package sprig + +import ( + "testing" +) + +type fixtureTO struct { + Name, Value string +} + +func TestTypeOf(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{typeOf .}}` + if err := runtv(tpl, "*sprig.fixtureTO", f); err != nil { + t.Error(err) + } +} + +func TestKindOf(t *testing.T) { + tpl := `{{kindOf .}}` + + f := fixtureTO{"hello", "world"} + if err := runtv(tpl, "struct", f); err != nil { + t.Error(err) + } + + f2 := []string{"hello"} + if err := runtv(tpl, "slice", f2); err != nil { + t.Error(err) + } + + var f3 *fixtureTO + if err := runtv(tpl, "ptr", f3); err != nil { + t.Error(err) + } +} + +func TestTypeIs(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{if typeIs "*sprig.fixtureTO" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + + f2 := "hello" + if err := runtv(tpl, "f", f2); err != nil { + t.Error(err) + } +} +func TestTypeIsLike(t *testing.T) { + f := "foo" + tpl := `{{if typeIsLike "string" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + + // Now make a pointer. Should still match. + f2 := &f + if err := runtv(tpl, "t", f2); err != nil { + t.Error(err) + } +} +func TestKindIs(t *testing.T) { + f := &fixtureTO{"hello", "world"} + tpl := `{{if kindIs "ptr" .}}t{{else}}f{{end}}` + if err := runtv(tpl, "t", f); err != nil { + t.Error(err) + } + f2 := "hello" + if err := runtv(tpl, "f", f2); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/regex.go b/util/sprig/regex.go new file mode 100644 index 00000000..fab55101 --- /dev/null +++ b/util/sprig/regex.go @@ -0,0 +1,83 @@ +package sprig + +import ( + "regexp" +) + +func regexMatch(regex string, s string) bool { + match, _ := regexp.MatchString(regex, s) + return match +} + +func mustRegexMatch(regex string, s string) (bool, error) { + return regexp.MatchString(regex, s) +} + +func regexFindAll(regex string, s string, n int) []string { + r := regexp.MustCompile(regex) + return r.FindAllString(s, n) +} + +func mustRegexFindAll(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.FindAllString(s, n), nil +} + +func regexFind(regex string, s string) string { + r := regexp.MustCompile(regex) + return r.FindString(s) +} + +func mustRegexFind(regex string, s string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.FindString(s), nil +} + +func regexReplaceAll(regex string, s string, repl string) string { + r := regexp.MustCompile(regex) + return r.ReplaceAllString(s, repl) +} + +func mustRegexReplaceAll(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllString(s, repl), nil +} + +func regexReplaceAllLiteral(regex string, s string, repl string) string { + r := regexp.MustCompile(regex) + return r.ReplaceAllLiteralString(s, repl) +} + +func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return "", err + } + return r.ReplaceAllLiteralString(s, repl), nil +} + +func regexSplit(regex string, s string, n int) []string { + r := regexp.MustCompile(regex) + return r.Split(s, n) +} + +func mustRegexSplit(regex string, s string, n int) ([]string, error) { + r, err := regexp.Compile(regex) + if err != nil { + return []string{}, err + } + return r.Split(s, n), nil +} + +func regexQuoteMeta(s string) string { + return regexp.QuoteMeta(s) +} diff --git a/util/sprig/regex_test.go b/util/sprig/regex_test.go new file mode 100644 index 00000000..60aafc29 --- /dev/null +++ b/util/sprig/regex_test.go @@ -0,0 +1,203 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegexMatch(t *testing.T) { + regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + + assert.True(t, regexMatch(regex, "test@acme.com")) + assert.True(t, regexMatch(regex, "Test@Acme.Com")) + assert.False(t, regexMatch(regex, "test")) + assert.False(t, regexMatch(regex, "test.com")) + assert.False(t, regexMatch(regex, "test@acme")) +} + +func TestMustRegexMatch(t *testing.T) { + regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" + + o, err := mustRegexMatch(regex, "test@acme.com") + assert.True(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "Test@Acme.Com") + assert.True(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test") + assert.False(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test.com") + assert.False(t, o) + assert.Nil(t, err) + + o, err = mustRegexMatch(regex, "test@acme") + assert.False(t, o) + assert.Nil(t, err) +} + +func TestRegexFindAll(t *testing.T) { + regex := "a{2}" + assert.Equal(t, 1, len(regexFindAll(regex, "aa", -1))) + assert.Equal(t, 1, len(regexFindAll(regex, "aaaaaaaa", 1))) + assert.Equal(t, 2, len(regexFindAll(regex, "aaaa", -1))) + assert.Equal(t, 0, len(regexFindAll(regex, "none", -1))) +} + +func TestMustRegexFindAll(t *testing.T) { + type args struct { + regex, s string + n int + } + cases := []struct { + expected int + args args + }{ + {1, args{"a{2}", "aa", -1}}, + {1, args{"a{2}", "aaaaaaaa", 1}}, + {2, args{"a{2}", "aaaa", -1}}, + {0, args{"a{2}", "none", -1}}, + } + + for _, c := range cases { + res, err := mustRegexFindAll(c.args.regex, c.args.s, c.args.n) + if err != nil { + t.Errorf("regexFindAll test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, len(res), "case %#v", c.args) + } +} + +func TestRegexFindl(t *testing.T) { + regex := "fo.?" + assert.Equal(t, "foo", regexFind(regex, "foorbar")) + assert.Equal(t, "foo", regexFind(regex, "foo foe fome")) + assert.Equal(t, "", regexFind(regex, "none")) +} + +func TestMustRegexFindl(t *testing.T) { + type args struct{ regex, s string } + cases := []struct { + expected string + args args + }{ + {"foo", args{"fo.?", "foorbar"}}, + {"foo", args{"fo.?", "foo foe fome"}}, + {"", args{"fo.?", "none"}}, + } + + for _, c := range cases { + res, err := mustRegexFind(c.args.regex, c.args.s) + if err != nil { + t.Errorf("regexFind test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexReplaceAll(t *testing.T) { + regex := "a(x*)b" + assert.Equal(t, "-T-T-", regexReplaceAll(regex, "-ab-axxb-", "T")) + assert.Equal(t, "--xx-", regexReplaceAll(regex, "-ab-axxb-", "$1")) + assert.Equal(t, "---", regexReplaceAll(regex, "-ab-axxb-", "$1W")) + assert.Equal(t, "-W-xxW-", regexReplaceAll(regex, "-ab-axxb-", "${1}W")) +} + +func TestMustRegexReplaceAll(t *testing.T) { + type args struct{ regex, s, repl string } + cases := []struct { + expected string + args args + }{ + {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, + {"--xx-", args{"a(x*)b", "-ab-axxb-", "$1"}}, + {"---", args{"a(x*)b", "-ab-axxb-", "$1W"}}, + {"-W-xxW-", args{"a(x*)b", "-ab-axxb-", "${1}W"}}, + } + + for _, c := range cases { + res, err := mustRegexReplaceAll(c.args.regex, c.args.s, c.args.repl) + if err != nil { + t.Errorf("regexReplaceAll test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexReplaceAllLiteral(t *testing.T) { + regex := "a(x*)b" + assert.Equal(t, "-T-T-", regexReplaceAllLiteral(regex, "-ab-axxb-", "T")) + assert.Equal(t, "-$1-$1-", regexReplaceAllLiteral(regex, "-ab-axxb-", "$1")) + assert.Equal(t, "-${1}-${1}-", regexReplaceAllLiteral(regex, "-ab-axxb-", "${1}")) +} + +func TestMustRegexReplaceAllLiteral(t *testing.T) { + type args struct{ regex, s, repl string } + cases := []struct { + expected string + args args + }{ + {"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}}, + {"-$1-$1-", args{"a(x*)b", "-ab-axxb-", "$1"}}, + {"-${1}-${1}-", args{"a(x*)b", "-ab-axxb-", "${1}"}}, + } + + for _, c := range cases { + res, err := mustRegexReplaceAllLiteral(c.args.regex, c.args.s, c.args.repl) + if err != nil { + t.Errorf("regexReplaceAllLiteral test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, res, "case %#v", c.args) + } +} + +func TestRegexSplit(t *testing.T) { + regex := "a" + assert.Equal(t, 4, len(regexSplit(regex, "banana", -1))) + assert.Equal(t, 0, len(regexSplit(regex, "banana", 0))) + assert.Equal(t, 1, len(regexSplit(regex, "banana", 1))) + assert.Equal(t, 2, len(regexSplit(regex, "banana", 2))) + + regex = "z+" + assert.Equal(t, 2, len(regexSplit(regex, "pizza", -1))) + assert.Equal(t, 0, len(regexSplit(regex, "pizza", 0))) + assert.Equal(t, 1, len(regexSplit(regex, "pizza", 1))) + assert.Equal(t, 2, len(regexSplit(regex, "pizza", 2))) +} + +func TestMustRegexSplit(t *testing.T) { + type args struct { + regex, s string + n int + } + cases := []struct { + expected int + args args + }{ + {4, args{"a", "banana", -1}}, + {0, args{"a", "banana", 0}}, + {1, args{"a", "banana", 1}}, + {2, args{"a", "banana", 2}}, + {2, args{"z+", "pizza", -1}}, + {0, args{"z+", "pizza", 0}}, + {1, args{"z+", "pizza", 1}}, + {2, args{"z+", "pizza", 2}}, + } + + for _, c := range cases { + res, err := mustRegexSplit(c.args.regex, c.args.s, c.args.n) + if err != nil { + t.Errorf("regexSplit test case %v failed with err %s", c, err) + } + assert.Equal(t, c.expected, len(res), "case %#v", c.args) + } +} + +func TestRegexQuoteMeta(t *testing.T) { + assert.Equal(t, "1\\.2\\.3", regexQuoteMeta("1.2.3")) + assert.Equal(t, "pretzel", regexQuoteMeta("pretzel")) +} diff --git a/util/sprig/strings.go b/util/sprig/strings.go new file mode 100644 index 00000000..3c62d6b6 --- /dev/null +++ b/util/sprig/strings.go @@ -0,0 +1,189 @@ +package sprig + +import ( + "encoding/base32" + "encoding/base64" + "fmt" + "reflect" + "strconv" + "strings" +) + +func base64encode(v string) string { + return base64.StdEncoding.EncodeToString([]byte(v)) +} + +func base64decode(v string) string { + data, err := base64.StdEncoding.DecodeString(v) + if err != nil { + return err.Error() + } + return string(data) +} + +func base32encode(v string) string { + return base32.StdEncoding.EncodeToString([]byte(v)) +} + +func base32decode(v string) string { + data, err := base32.StdEncoding.DecodeString(v) + if err != nil { + return err.Error() + } + return string(data) +} + +func quote(str ...interface{}) string { + out := make([]string, 0, len(str)) + for _, s := range str { + if s != nil { + out = append(out, fmt.Sprintf("%q", strval(s))) + } + } + return strings.Join(out, " ") +} + +func squote(str ...interface{}) string { + out := make([]string, 0, len(str)) + for _, s := range str { + if s != nil { + out = append(out, fmt.Sprintf("'%v'", s)) + } + } + return strings.Join(out, " ") +} + +func cat(v ...interface{}) string { + v = removeNilElements(v) + r := strings.TrimSpace(strings.Repeat("%v ", len(v))) + return fmt.Sprintf(r, v...) +} + +func indent(spaces int, v string) string { + pad := strings.Repeat(" ", spaces) + return pad + strings.Replace(v, "\n", "\n"+pad, -1) +} + +func nindent(spaces int, v string) string { + return "\n" + indent(spaces, v) +} + +func replace(old, new, src string) string { + return strings.Replace(src, old, new, -1) +} + +func plural(one, many string, count int) string { + if count == 1 { + return one + } + return many +} + +func strslice(v interface{}) []string { + switch v := v.(type) { + case []string: + return v + case []interface{}: + b := make([]string, 0, len(v)) + for _, s := range v { + if s != nil { + b = append(b, strval(s)) + } + } + return b + default: + val := reflect.ValueOf(v) + switch val.Kind() { + case reflect.Array, reflect.Slice: + l := val.Len() + b := make([]string, 0, l) + for i := 0; i < l; i++ { + value := val.Index(i).Interface() + if value != nil { + b = append(b, strval(value)) + } + } + return b + default: + if v == nil { + return []string{} + } + + return []string{strval(v)} + } + } +} + +func removeNilElements(v []interface{}) []interface{} { + newSlice := make([]interface{}, 0, len(v)) + for _, i := range v { + if i != nil { + newSlice = append(newSlice, i) + } + } + return newSlice +} + +func strval(v interface{}) string { + switch v := v.(type) { + case string: + return v + case []byte: + return string(v) + case error: + return v.Error() + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func trunc(c int, s string) string { + if c < 0 && len(s)+c > 0 { + return s[len(s)+c:] + } + if c >= 0 && len(s) > c { + return s[:c] + } + return s +} + +func join(sep string, v interface{}) string { + return strings.Join(strslice(v), sep) +} + +func split(sep, orig string) map[string]string { + parts := strings.Split(orig, sep) + res := make(map[string]string, len(parts)) + for i, v := range parts { + res["_"+strconv.Itoa(i)] = v + } + return res +} + +func splitn(sep string, n int, orig string) map[string]string { + parts := strings.SplitN(orig, sep, n) + res := make(map[string]string, len(parts)) + for i, v := range parts { + res["_"+strconv.Itoa(i)] = v + } + return res +} + +// substring creates a substring of the given string. +// +// If start is < 0, this calls string[:end]. +// +// If start is >= 0 and end < 0 or end bigger than s length, this calls string[start:] +// +// Otherwise, this calls string[start, end]. +func substring(start, end int, s string) string { + if start < 0 { + return s[:end] + } + if end < 0 || end > len(s) { + return s[start:] + } + return s[start:end] +} diff --git a/util/sprig/strings_test.go b/util/sprig/strings_test.go new file mode 100644 index 00000000..38c96c4e --- /dev/null +++ b/util/sprig/strings_test.go @@ -0,0 +1,233 @@ +package sprig + +import ( + "encoding/base32" + "encoding/base64" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSubstr(t *testing.T) { + tpl := `{{"fooo" | substr 0 3 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestSubstr_shorterString(t *testing.T) { + tpl := `{{"foo" | substr 0 10 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestTrunc(t *testing.T) { + tpl := `{{ "foooooo" | trunc 3 }}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaar" | trunc -3 }}` + if err := runt(tpl, "aar"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaar" | trunc -999 }}` + if err := runt(tpl, "baaaaaar"); err != nil { + t.Error(err) + } + tpl = `{{ "baaaaaz" | trunc 0 }}` + if err := runt(tpl, ""); err != nil { + t.Error(err) + } +} + +func TestQuote(t *testing.T) { + tpl := `{{quote "a" "b" "c"}}` + if err := runt(tpl, `"a" "b" "c"`); err != nil { + t.Error(err) + } + tpl = `{{quote "\"a\"" "b" "c"}}` + if err := runt(tpl, `"\"a\"" "b" "c"`); err != nil { + t.Error(err) + } + tpl = `{{quote 1 2 3 }}` + if err := runt(tpl, `"1" "2" "3"`); err != nil { + t.Error(err) + } + tpl = `{{ .value | quote }}` + values := map[string]interface{}{"value": nil} + if err := runtv(tpl, ``, values); err != nil { + t.Error(err) + } +} +func TestSquote(t *testing.T) { + tpl := `{{squote "a" "b" "c"}}` + if err := runt(tpl, `'a' 'b' 'c'`); err != nil { + t.Error(err) + } + tpl = `{{squote 1 2 3 }}` + if err := runt(tpl, `'1' '2' '3'`); err != nil { + t.Error(err) + } + tpl = `{{ .value | squote }}` + values := map[string]interface{}{"value": nil} + if err := runtv(tpl, ``, values); err != nil { + t.Error(err) + } +} + +func TestContains(t *testing.T) { + // Mainly, we're just verifying the paramater order swap. + tests := []string{ + `{{if contains "cat" "fair catch"}}1{{end}}`, + `{{if hasPrefix "cat" "catch"}}1{{end}}`, + `{{if hasSuffix "cat" "ducat"}}1{{end}}`, + } + for _, tt := range tests { + if err := runt(tt, "1"); err != nil { + t.Error(err) + } + } +} + +func TestTrim(t *testing.T) { + tests := []string{ + `{{trim " 5.00 "}}`, + `{{trimAll "$" "$5.00$"}}`, + `{{trimPrefix "$" "$5.00"}}`, + `{{trimSuffix "$" "5.00$"}}`, + } + for _, tt := range tests { + if err := runt(tt, "5.00"); err != nil { + t.Error(err) + } + } +} + +func TestSplit(t *testing.T) { + tpl := `{{$v := "foo$bar$baz" | split "$"}}{{$v._0}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestSplitn(t *testing.T) { + tpl := `{{$v := "foo$bar$baz" | splitn "$" 2}}{{$v._0}}` + if err := runt(tpl, "foo"); err != nil { + t.Error(err) + } +} + +func TestToString(t *testing.T) { + tpl := `{{ toString 1 | kindOf }}` + assert.NoError(t, runt(tpl, "string")) +} + +func TestToStrings(t *testing.T) { + tpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}` + assert.NoError(t, runt(tpl, "string")) + tpl = `{{ list 1 .value 2 | toStrings }}` + values := map[string]interface{}{"value": nil} + if err := runtv(tpl, `[1 2]`, values); err != nil { + t.Error(err) + } +} + +func TestJoin(t *testing.T) { + assert.NoError(t, runt(`{{ tuple "a" "b" "c" | join "-" }}`, "a-b-c")) + assert.NoError(t, runt(`{{ tuple 1 2 3 | join "-" }}`, "1-2-3")) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]interface{}{"V": []string{"a", "b", "c"}})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]interface{}{"V": "abc"})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]interface{}{"V": []int{1, 2, 3}})) + assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]interface{}{"value": []interface{}{"1", nil, "2"}})) +} + +func TestSortAlpha(t *testing.T) { + // Named `append` in the function map + tests := map[string]string{ + `{{ list "c" "a" "b" | sortAlpha | join "" }}`: "abc", + `{{ list 2 1 4 3 | sortAlpha | join "" }}`: "1234", + } + for tpl, expect := range tests { + assert.NoError(t, runt(tpl, expect)) + } +} +func TestBase64EncodeDecode(t *testing.T) { + magicWord := "coffee" + expect := base64.StdEncoding.EncodeToString([]byte(magicWord)) + + if expect == magicWord { + t.Fatal("Encoder doesn't work.") + } + + tpl := `{{b64enc "coffee"}}` + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + tpl = fmt.Sprintf("{{b64dec %q}}", expect) + if err := runt(tpl, magicWord); err != nil { + t.Error(err) + } +} +func TestBase32EncodeDecode(t *testing.T) { + magicWord := "coffee" + expect := base32.StdEncoding.EncodeToString([]byte(magicWord)) + + if expect == magicWord { + t.Fatal("Encoder doesn't work.") + } + + tpl := `{{b32enc "coffee"}}` + if err := runt(tpl, expect); err != nil { + t.Error(err) + } + tpl = fmt.Sprintf("{{b32dec %q}}", expect) + if err := runt(tpl, magicWord); err != nil { + t.Error(err) + } +} + +func TestCat(t *testing.T) { + tpl := `{{$b := "b"}}{{"c" | cat "a" $b}}` + if err := runt(tpl, "a b c"); err != nil { + t.Error(err) + } + tpl = `{{ .value | cat "a" "b"}}` + values := map[string]interface{}{"value": nil} + if err := runtv(tpl, "a b", values); err != nil { + t.Error(err) + } +} + +func TestIndent(t *testing.T) { + tpl := `{{indent 4 "a\nb\nc"}}` + if err := runt(tpl, " a\n b\n c"); err != nil { + t.Error(err) + } +} + +func TestNindent(t *testing.T) { + tpl := `{{nindent 4 "a\nb\nc"}}` + if err := runt(tpl, "\n a\n b\n c"); err != nil { + t.Error(err) + } +} + +func TestReplace(t *testing.T) { + tpl := `{{"I Am Henry VIII" | replace " " "-"}}` + if err := runt(tpl, "I-Am-Henry-VIII"); err != nil { + t.Error(err) + } +} + +func TestPlural(t *testing.T) { + tpl := `{{$num := len "two"}}{{$num}} {{$num | plural "1 char" "chars"}}` + if err := runt(tpl, "3 chars"); err != nil { + t.Error(err) + } + tpl = `{{len "t" | plural "cheese" "%d chars"}}` + if err := runt(tpl, "cheese"); err != nil { + t.Error(err) + } +} diff --git a/util/sprig/url.go b/util/sprig/url.go new file mode 100644 index 00000000..b8e120e1 --- /dev/null +++ b/util/sprig/url.go @@ -0,0 +1,66 @@ +package sprig + +import ( + "fmt" + "net/url" + "reflect" +) + +func dictGetOrEmpty(dict map[string]interface{}, key string) string { + value, ok := dict[key] + if !ok { + return "" + } + tp := reflect.TypeOf(value).Kind() + if tp != reflect.String { + panic(fmt.Sprintf("unable to parse %s key, must be of type string, but %s found", key, tp.String())) + } + return reflect.ValueOf(value).String() +} + +// parses given URL to return dict object +func urlParse(v string) map[string]interface{} { + dict := map[string]interface{}{} + parsedURL, err := url.Parse(v) + if err != nil { + panic(fmt.Sprintf("unable to parse url: %s", err)) + } + dict["scheme"] = parsedURL.Scheme + dict["host"] = parsedURL.Host + dict["hostname"] = parsedURL.Hostname() + dict["path"] = parsedURL.Path + dict["query"] = parsedURL.RawQuery + dict["opaque"] = parsedURL.Opaque + dict["fragment"] = parsedURL.Fragment + if parsedURL.User != nil { + dict["userinfo"] = parsedURL.User.String() + } else { + dict["userinfo"] = "" + } + + return dict +} + +// join given dict to URL string +func urlJoin(d map[string]interface{}) string { + resURL := url.URL{ + Scheme: dictGetOrEmpty(d, "scheme"), + Host: dictGetOrEmpty(d, "host"), + Path: dictGetOrEmpty(d, "path"), + RawQuery: dictGetOrEmpty(d, "query"), + Opaque: dictGetOrEmpty(d, "opaque"), + Fragment: dictGetOrEmpty(d, "fragment"), + } + userinfo := dictGetOrEmpty(d, "userinfo") + var user *url.Userinfo + if userinfo != "" { + tempURL, err := url.Parse(fmt.Sprintf("proto://%s@host", userinfo)) + if err != nil { + panic(fmt.Sprintf("unable to parse userinfo in dict: %s", err)) + } + user = tempURL.User + } + + resURL.User = user + return resURL.String() +} diff --git a/util/sprig/url_test.go b/util/sprig/url_test.go new file mode 100644 index 00000000..f9c00b17 --- /dev/null +++ b/util/sprig/url_test.go @@ -0,0 +1,87 @@ +package sprig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var urlTests = map[string]map[string]interface{}{ + "proto://auth@host:80/path?query#fragment": { + "fragment": "fragment", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "query", + "scheme": "proto", + "userinfo": "auth", + }, + "proto://host:80/path": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "", + "scheme": "proto", + "userinfo": "", + }, + "something": { + "fragment": "", + "host": "", + "hostname": "", + "opaque": "", + "path": "something", + "query": "", + "scheme": "", + "userinfo": "", + }, + "proto://user:passwor%20d@host:80/path": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/path", + "query": "", + "scheme": "proto", + "userinfo": "user:passwor%20d", + }, + "proto://host:80/pa%20th?key=val%20ue": { + "fragment": "", + "host": "host:80", + "hostname": "host", + "opaque": "", + "path": "/pa th", + "query": "key=val%20ue", + "scheme": "proto", + "userinfo": "", + }, +} + +func TestUrlParse(t *testing.T) { + // testing that function is exported and working properly + assert.NoError(t, runt( + `{{ index ( urlParse "proto://auth@host:80/path?query#fragment" ) "host" }}`, + "host:80")) + + // testing scenarios + for url, expected := range urlTests { + assert.EqualValues(t, expected, urlParse(url)) + } +} + +func TestUrlJoin(t *testing.T) { + tests := map[string]string{ + `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "proto") }}`: "proto://host:80/path?query#fragment", + `{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "scheme" "proto" "userinfo" "ASDJKJSD") }}`: "proto://ASDJKJSD@host:80/path#fragment", + } + for tpl, expected := range tests { + assert.NoError(t, runt(tpl, expected)) + } + + for expected, urlMap := range urlTests { + assert.EqualValues(t, expected, urlJoin(urlMap)) + } + +} From 650f492d7dd2b3196474cc3ce334767ff63a367f Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Mon, 7 Jul 2025 22:47:41 -0600 Subject: [PATCH 34/87] make tests happy --- docs/releases.md | 2 +- docs/sprig/os.md | 24 ------ docs/sprig/semver.md | 151 ------------------------------------- go.mod | 4 +- server/server.go | 6 +- util/sprig/defaults.go | 6 -- util/sprig/functions.go | 4 +- util/sprig/list.go | 26 +++---- util/sprig/numeric_test.go | 14 ++-- 9 files changed, 27 insertions(+), 210 deletions(-) delete mode 100644 docs/sprig/os.md delete mode 100644 docs/sprig/semver.md diff --git a/docs/releases.md b/docs/releases.md index ed728fcb..5e18edab 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1440,7 +1440,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) * Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) * Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) -* You can now use [Slim-Sprig](https://github.com/go-task/slim-sprig) functions in message/title templates ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) +* You can now use a subset of [Sprig](https://github.com/Masterminds/sprig) functions in message/title templates ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) **Languages** diff --git a/docs/sprig/os.md b/docs/sprig/os.md deleted file mode 100644 index e6120c03..00000000 --- a/docs/sprig/os.md +++ /dev/null @@ -1,24 +0,0 @@ -# OS Functions - -_WARNING:_ These functions can lead to information leakage if not used -appropriately. - -_WARNING:_ Some notable implementations of Sprig (such as -[Kubernetes Helm](http://helm.sh)) _do not provide these functions for security -reasons_. - -## env - -The `env` function reads an environment variable: - -``` -env "HOME" -``` - -## expandenv - -To substitute environment variables in a string, use `expandenv`: - -``` -expandenv "Your path is set to $PATH" -``` diff --git a/docs/sprig/semver.md b/docs/sprig/semver.md deleted file mode 100644 index f049613d..00000000 --- a/docs/sprig/semver.md +++ /dev/null @@ -1,151 +0,0 @@ -# Semantic Version Functions - -Some version schemes are easily parseable and comparable. Sprig provides functions -for working with [SemVer 2](http://semver.org) versions. - -## semver - -The `semver` function parses a string into a Semantic Version: - -``` -$version := semver "1.2.3-alpha.1+123" -``` - -_If the parser fails, it will cause template execution to halt with an error._ - -At this point, `$version` is a pointer to a `Version` object with the following -properties: - -- `$version.Major`: The major number (`1` above) -- `$version.Minor`: The minor number (`2` above) -- `$version.Patch`: The patch number (`3` above) -- `$version.Prerelease`: The prerelease (`alpha.1` above) -- `$version.Metadata`: The build metadata (`123` above) -- `$version.Original`: The original version as a string - -Additionally, you can compare a `Version` to another `version` using the `Compare` -function: - -``` -semver "1.4.3" | (semver "1.2.3").Compare -``` - -The above will return `-1`. - -The return values are: - -- `-1` if the given semver is greater than the semver whose `Compare` method was called -- `1` if the version who's `Compare` function was called is greater. -- `0` if they are the same version - -(Note that in SemVer, the `Metadata` field is not compared during version -comparison operations.) - -## semverCompare - -A more robust comparison function is provided as `semverCompare`. It returns `true` if -the constraint matches, or `false` if it does not match. This version supports version ranges: - -- `semverCompare "1.2.3" "1.2.3"` checks for an exact match -- `semverCompare "^1.2.0" "1.2.3"` checks that the major and minor versions match, and that the patch - number of the second version is _greater than or equal to_ the first parameter. - -The SemVer functions use the [Masterminds semver library](https://github.com/Masterminds/semver), -from the creators of Sprig. - -## Basic Comparisons - -There are two elements to the comparisons. First, a comparison string is a list -of space or comma separated AND comparisons. These are then separated by || (OR) -comparisons. For example, `">= 1.2 < 3.0.0 || >= 4.2.3"` is looking for a -comparison that's greater than or equal to 1.2 and less than 3.0.0 or is -greater than or equal to 4.2.3. - -The basic comparisons are: - -- `=`: equal (aliased to no operator) -- `!=`: not equal -- `>`: greater than -- `<`: less than -- `>=`: greater than or equal to -- `<=`: less than or equal to - -_Note, according to the Semantic Version specification pre-releases may not be -API compliant with their release counterpart. It says,_ - -## Working With Prerelease Versions - -Pre-releases, for those not familiar with them, are used for software releases -prior to stable or generally available releases. Examples of prereleases include -development, alpha, beta, and release candidate releases. A prerelease may be -a version such as `1.2.3-beta.1` while the stable release would be `1.2.3`. In the -order of precedence, prereleases come before their associated releases. In this -example `1.2.3-beta.1 < 1.2.3`. - -According to the Semantic Version specification prereleases may not be -API compliant with their release counterpart. It says, - -> A pre-release version indicates that the version is unstable and might not satisfy the intended compatibility requirements as denoted by its associated normal version. - -SemVer comparisons using constraints without a prerelease comparator will skip -prerelease versions. For example, `>=1.2.3` will skip prereleases when looking -at a list of releases while `>=1.2.3-0` will evaluate and find prereleases. - -The reason for the `0` as a pre-release version in the example comparison is -because pre-releases can only contain ASCII alphanumerics and hyphens (along with -`.` separators), per the spec. Sorting happens in ASCII sort order, again per the -spec. The lowest character is a `0` in ASCII sort order -(see an [ASCII Table](http://www.asciitable.com/)) - -Understanding ASCII sort ordering is important because A-Z comes before a-z. That -means `>=1.2.3-BETA` will return `1.2.3-alpha`. What you might expect from case -sensitivity doesn't apply here. This is due to ASCII sort ordering which is what -the spec specifies. - -## Hyphen Range Comparisons - -There are multiple methods to handle ranges and the first is hyphens ranges. -These look like: - -- `1.2 - 1.4.5` which is equivalent to `>= 1.2 <= 1.4.5` -- `2.3.4 - 4.5` which is equivalent to `>= 2.3.4 <= 4.5` - -## Wildcards In Comparisons - -The `x`, `X`, and `*` characters can be used as a wildcard character. This works -for all comparison operators. When used on the `=` operator it falls -back to the patch level comparison (see tilde below). For example, - -- `1.2.x` is equivalent to `>= 1.2.0, < 1.3.0` -- `>= 1.2.x` is equivalent to `>= 1.2.0` -- `<= 2.x` is equivalent to `< 3` -- `*` is equivalent to `>= 0.0.0` - -## Tilde Range Comparisons (Patch) - -The tilde (`~`) comparison operator is for patch level ranges when a minor -version is specified and major level changes when the minor number is missing. -For example, - -- `~1.2.3` is equivalent to `>= 1.2.3, < 1.3.0` -- `~1` is equivalent to `>= 1, < 2` -- `~2.3` is equivalent to `>= 2.3, < 2.4` -- `~1.2.x` is equivalent to `>= 1.2.0, < 1.3.0` -- `~1.x` is equivalent to `>= 1, < 2` - -## Caret Range Comparisons (Major) - -The caret (`^`) comparison operator is for major level changes once a stable -(1.0.0) release has occurred. Prior to a 1.0.0 release the minor versions acts -as the API stability level. This is useful when comparisons of API versions as a -major change is API breaking. For example, - -- `^1.2.3` is equivalent to `>= 1.2.3, < 2.0.0` -- `^1.2.x` is equivalent to `>= 1.2.0, < 2.0.0` -- `^2.3` is equivalent to `>= 2.3, < 3` -- `^2.x` is equivalent to `>= 2.0.0, < 3` -- `^0.2.3` is equivalent to `>=0.2.3 <0.3.0` -- `^0.2` is equivalent to `>=0.2.0 <0.3.0` -- `^0.0.3` is equivalent to `>=0.0.3 <0.0.4` -- `^0.0` is equivalent to `>=0.0.0 <0.1.0` -- `^0` is equivalent to `>=0.0.0 <1.0.0` diff --git a/go.mod b/go.mod index 7bddeb07..88b88463 100644 --- a/go.mod +++ b/go.mod @@ -32,9 +32,11 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.16.1 github.com/SherClockHolmes/webpush-go v1.4.0 + github.com/google/uuid v1.6.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.22.0 github.com/stripe/stripe-go/v74 v74.30.0 + golang.org/x/text v0.26.0 ) require ( @@ -67,7 +69,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -93,7 +94,6 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect diff --git a/server/server.go b/server/server.go index 94461fbb..7e5fbb94 100644 --- a/server/server.go +++ b/server/server.go @@ -1133,11 +1133,7 @@ func replaceTemplate(tpl string, source string) (string, error) { if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } - sprigFuncs := sprig.FuncMap() - // remove unsafe functions - delete(sprigFuncs, "env") - delete(sprigFuncs, "expandenv") - t, err := template.New("").Funcs(sprigFuncs).Parse(tpl) + t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl) if err != nil { return "", errHTTPBadRequestTemplateInvalid } diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 201b7e24..6a828a2a 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -3,16 +3,10 @@ package sprig import ( "bytes" "encoding/json" - "math/rand" "reflect" "strings" - "time" ) -func init() { - rand.Seed(time.Now().UnixNano()) -} - // dfault checks whether `given` is set, and returns default if not set. // // This returns `d` if `given` appears not to be set, and `given` otherwise. diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 8549e99c..3ea46924 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -11,6 +11,8 @@ import ( "strings" ttemplate "text/template" "time" + + "golang.org/x/text/cases" ) // FuncMap produces the function map. @@ -107,7 +109,7 @@ var genericMap = map[string]interface{}{ "trim": strings.TrimSpace, "upper": strings.ToUpper, "lower": strings.ToLower, - "title": strings.Title, + "title": cases.Title, "substr": substring, // Switch order so that "foo" | repeat 5 "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, diff --git a/util/sprig/list.go b/util/sprig/list.go index ca0fbb78..f4e95dda 100644 --- a/util/sprig/list.go +++ b/util/sprig/list.go @@ -39,7 +39,7 @@ func mustPush(list interface{}, v interface{}) ([]interface{}, error) { return append(nl, v), nil default: - return nil, fmt.Errorf("Cannot push on type %s", tp) + return nil, fmt.Errorf("cannot push on type %s", tp) } } @@ -69,7 +69,7 @@ func mustPrepend(list interface{}, v interface{}) ([]interface{}, error) { return append([]interface{}{v}, nl...), nil default: - return nil, fmt.Errorf("Cannot prepend on type %s", tp) + return nil, fmt.Errorf("cannot prepend on type %s", tp) } } @@ -113,7 +113,7 @@ func mustChunk(size int, list interface{}) ([][]interface{}, error) { return nl, nil default: - return nil, fmt.Errorf("Cannot chunk type %s", tp) + return nil, fmt.Errorf("cannot chunk type %s", tp) } } @@ -139,7 +139,7 @@ func mustLast(list interface{}) (interface{}, error) { return l2.Index(l - 1).Interface(), nil default: - return nil, fmt.Errorf("Cannot find last on type %s", tp) + return nil, fmt.Errorf("cannot find last on type %s", tp) } } @@ -165,7 +165,7 @@ func mustFirst(list interface{}) (interface{}, error) { return l2.Index(0).Interface(), nil default: - return nil, fmt.Errorf("Cannot find first on type %s", tp) + return nil, fmt.Errorf("cannot find first on type %s", tp) } } @@ -196,7 +196,7 @@ func mustRest(list interface{}) ([]interface{}, error) { return nl, nil default: - return nil, fmt.Errorf("Cannot find rest on type %s", tp) + return nil, fmt.Errorf("cannot find rest on type %s", tp) } } @@ -227,7 +227,7 @@ func mustInitial(list interface{}) ([]interface{}, error) { return nl, nil default: - return nil, fmt.Errorf("Cannot find initial on type %s", tp) + return nil, fmt.Errorf("cannot find initial on type %s", tp) } } @@ -267,7 +267,7 @@ func mustReverse(v interface{}) ([]interface{}, error) { return nl, nil default: - return nil, fmt.Errorf("Cannot find reverse on type %s", tp) + return nil, fmt.Errorf("cannot find reverse on type %s", tp) } } @@ -298,7 +298,7 @@ func mustCompact(list interface{}) ([]interface{}, error) { return nl, nil default: - return nil, fmt.Errorf("Cannot compact on type %s", tp) + return nil, fmt.Errorf("cannot compact on type %s", tp) } } @@ -329,7 +329,7 @@ func mustUniq(list interface{}) ([]interface{}, error) { return dest, nil default: - return nil, fmt.Errorf("Cannot find uniq on type %s", tp) + return nil, fmt.Errorf("cannot find uniq on type %s", tp) } } @@ -369,7 +369,7 @@ func mustWithout(list interface{}, omit ...interface{}) ([]interface{}, error) { return res, nil default: - return nil, fmt.Errorf("Cannot find without on type %s", tp) + return nil, fmt.Errorf("cannot find without on type %s", tp) } } @@ -401,7 +401,7 @@ func mustHas(needle interface{}, haystack interface{}) (bool, error) { return false, nil default: - return false, fmt.Errorf("Cannot find has on type %s", tp) + return false, fmt.Errorf("cannot find has on type %s", tp) } } @@ -457,7 +457,7 @@ func concat(lists ...interface{}) interface{} { res = append(res, l2.Index(i).Interface()) } default: - panic(fmt.Sprintf("Cannot concat type %s as list", tp)) + panic(fmt.Sprintf("cannot concat type %s as list", tp)) } } return res diff --git a/util/sprig/numeric_test.go b/util/sprig/numeric_test.go index 94e8a6d4..573873d8 100644 --- a/util/sprig/numeric_test.go +++ b/util/sprig/numeric_test.go @@ -101,7 +101,7 @@ func TestToFloat64(t *testing.T) { if target != toFloat64("102") { t.Errorf("Expected 102") } - if 0 != toFloat64("frankie") { + if toFloat64("frankie") != 0 { t.Errorf("Expected 0") } if target != toFloat64(uint16(102)) { @@ -110,10 +110,10 @@ func TestToFloat64(t *testing.T) { if target != toFloat64(uint64(102)) { t.Errorf("Expected 102") } - if 102.1234 != toFloat64(float64(102.1234)) { + if toFloat64(float64(102.1234)) != 102.1234 { t.Errorf("Expected 102.1234") } - if 1 != toFloat64(true) { + if toFloat64(true) != 1 { t.Errorf("Expected 102") } } @@ -137,7 +137,7 @@ func TestToInt64(t *testing.T) { if target != toInt64("102") { t.Errorf("Expected 102") } - if 0 != toInt64("frankie") { + if toInt64("frankie") != 0 { t.Errorf("Expected 0") } if target != toInt64(uint16(102)) { @@ -149,7 +149,7 @@ func TestToInt64(t *testing.T) { if target != toInt64(float64(102.1234)) { t.Errorf("Expected 102") } - if 1 != toInt64(true) { + if toInt64(true) != 1 { t.Errorf("Expected 102") } } @@ -174,7 +174,7 @@ func TestToInt(t *testing.T) { if target != toInt("102") { t.Errorf("Expected 102") } - if 0 != toInt("frankie") { + if toInt("frankie") != 0 { t.Errorf("Expected 0") } if target != toInt(uint16(102)) { @@ -186,7 +186,7 @@ func TestToInt(t *testing.T) { if target != toInt(float64(102.1234)) { t.Errorf("Expected 102") } - if 1 != toInt(true) { + if toInt(true) != 1 { t.Errorf("Expected 102") } } From c0b5151baeee36cfeeb0fea1c3c83519d8acb490 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 10 Jul 2025 20:50:29 +0200 Subject: [PATCH 35/87] Predefined users --- .goreleaser.yml | 84 +++++++++++++++++++------------------------- Makefile | 2 +- cmd/serve.go | 32 ++++++++++++++--- cmd/user.go | 19 +++++++--- server/config.go | 3 +- server/server.go | 2 ++ user/manager.go | 28 ++++++++++++--- user/manager_test.go | 54 +++++++++++++++++++++++++--- 8 files changed, 157 insertions(+), 67 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..82ab53e2 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } diff --git a/cmd/serve.go b/cmd/serve.go index 516356c5..abd9ac06 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,7 +52,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provisioned-users", Aliases: []string{"auth_provisioned_users"}, EnvVars: []string{"NTFY_AUTH_PROVISIONED_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -158,7 +158,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authUsers := c.StringSlice("auth-users") + authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users") + //authProvisionedAccessRaw := c.StringSlice("auth-provisioned-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -348,11 +349,33 @@ func execServe(c *cli.Context) error { webRoot = "/" + webRoot } - // Default auth permissions + // Convert default auth permission, read provisioned users authDefault, err := user.ParsePermission(authDefaultAccess) if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } + authProvisionedUsers := make([]*user.User, 0) + for _, userLine := range authProvisionedUsersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return fmt.Errorf("invalid provisioned user %s, expected format: 'name:hash:role'", userLine) + } + username := strings.TrimSpace(parts[0]) + passwordHash := strings.TrimSpace(parts[1]) + role := user.Role(strings.TrimSpace(parts[2])) + if !user.AllowedUsername(username) { + return fmt.Errorf("invalid provisioned user %s, username invalid", userLine) + } else if passwordHash == "" { + return fmt.Errorf("invalid provisioned user %s, password hash cannot be empty", userLine) + } else if !user.AllowedRole(role) { + return fmt.Errorf("invalid provisioned user %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + authProvisionedUsers = append(authProvisionedUsers, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + }) + } // Special case: Unset default if listenHTTP == "-" { @@ -408,7 +431,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthUsers = nil // FIXME + conf.AuthProvisionedUsers = authProvisionedUsers + conf.AuthProvisionedAccess = nil // FIXME conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/cmd/user.go b/cmd/user.go index 9902dace..7519438c 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -224,7 +224,7 @@ func execUserDel(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.RemoveUser(username); err != nil { @@ -250,7 +250,7 @@ func execUserChangePass(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if password == "" { @@ -278,7 +278,7 @@ func execUserChangeRole(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if err := manager.ChangeRole(username, role); err != nil { @@ -302,7 +302,7 @@ func execUserChangeTier(c *cli.Context) error { if err != nil { return err } - if _, err := manager.User(username); err == user.ErrUserNotFound { + if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } if tier == tierReset { @@ -344,7 +344,16 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { if err != nil { return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval) + authConfig := &user.Config{ + Filename: authFile, + StartupQueries: authStartupQueries, + DefaultAccess: authDefault, + ProvisionedUsers: nil, //FIXME + ProvisionedAccess: nil, //FIXME + BcryptCost: user.DefaultUserPasswordBcryptCost, + QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, + } + return user.NewManager(authConfig) } func readPasswordAndConfirm(c *cli.Context) (string, error) { diff --git a/server/config.go b/server/config.go index 67554021..c163614f 100644 --- a/server/config.go +++ b/server/config.go @@ -93,7 +93,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthUsers []user.User + AuthProvisionedUsers []*user.User + AuthProvisionedAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 10ad7d8e..cba9b181 100644 --- a/server/server.go +++ b/server/server.go @@ -193,6 +193,8 @@ func New(conf *Config) (*Server, error) { Filename: conf.AuthFile, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, + ProvisionedUsers: conf.AuthProvisionedUsers, + ProvisionedAccess: conf.AuthProvisionedAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 04c3c878..8932f34a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -449,13 +449,13 @@ type Manager struct { } type Config struct { - Filename string - StartupQueries string + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches ProvisionedUsers []*User // Predefined users to create on startup ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup - BcryptCost int // Makes testing easier - QueueWriterInterval time.Duration + QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database + BcryptCost int // Cost of generated passwords; lowering makes testing faster } var _ Auther = (*Manager)(nil) @@ -469,7 +469,6 @@ func NewManager(config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } - // Open DB and run setup queries db, err := sql.Open("sqlite3", config.Filename) if err != nil { @@ -487,6 +486,9 @@ func NewManager(config *Config) (*Manager, error) { statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), } + if err := manager.provisionUsers(); err != nil { + return nil, err + } go manager.asyncQueueWriter(config.QueueWriterInterval) return manager, nil } @@ -1522,6 +1524,22 @@ func (a *Manager) Close() error { return a.db.Close() } +func (a *Manager) provisionUsers() error { + for _, user := range a.config.ProvisionedUsers { + if err := a.AddUser(user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { + return err + } + } + for username, grants := range a.config.ProvisionedAccess { + for _, grant := range grants { + if err := a.AllowAccess(username, grant.TopicPattern, grant.Allow); err != nil { + return err + } + } + } + return nil +} + // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, // and escapes '_', assuming '\' as escape character. func toSQLWildcard(s string) string { diff --git a/user/manager_test.go b/user/manager_test.go index 89f35e3c..b57c762c 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -731,7 +731,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) { } func TestManager_EnqueueStats_ResetStats(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -773,7 +780,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) { } func TestManager_EnqueueTokenUpdate(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -806,7 +820,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) { } func TestManager_ChangeSettings(t *testing.T) { - a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond) + conf := &Config{ + Filename: filepath.Join(t.TempDir(), "db"), + StartupQueries: "", + DefaultAccess: PermissionReadWrite, + BcryptCost: bcrypt.MinCost, + QueueWriterInterval: 1500 * time.Millisecond, + } + a, err := NewManager(conf) require.Nil(t, err) require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) @@ -1075,6 +1096,24 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite)) } +func TestManager_WithProvisionedUsers(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + conf := &Config{ + Filename: f, + DefaultAccess: PermissionReadWrite, + ProvisionedUsers: []*User{ + {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + }, + } + a, err := NewManager(conf) + require.Nil(t, err) + users, err := a.Users() + require.Nil(t, err) + for _, u := range users { + fmt.Println(u.ID, u.Name, u.Role) + } +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) @@ -1336,7 +1375,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager { } func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager { - a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval) + conf := &Config{ + Filename: filename, + StartupQueries: startupQueries, + DefaultAccess: defaultAccess, + BcryptCost: bcryptCost, + QueueWriterInterval: statsWriterInterval, + } + a, err := NewManager(conf) require.Nil(t, err) return a } From 8d6f1eecdfd6f9bf54bb785e4f317cd9c7ee6c0c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 10 Jul 2025 21:06:39 +0200 Subject: [PATCH 36/87] Fix build --- .goreleaser.yml | 84 ++++++++++++++++++++++--------------------------- Makefile | 2 +- 2 files changed, 38 insertions(+), 48 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..82ab53e2 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } From 1ce08a18c03966f0b69a34d3afac4a99b0ab91c5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 10 Jul 2025 21:17:58 +0200 Subject: [PATCH 37/87] Bump release notes --- Makefile | 2 +- docs/install.md | 60 ++++++++++++++++++++++++------------------------ docs/releases.md | 32 +++++++++++++++----------- 3 files changed, 50 insertions(+), 44 deletions(-) diff --git a/Makefile b/Makefile index 82ab53e2..575bb788 100644 --- a/Makefile +++ b/Makefile @@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check goreleaser release --clean release-snapshot: clean cli-deps docs web check - goreleaser release --snapshot --skip-publish --clean + goreleaser release --snapshot --clean release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) diff --git a/docs/install.md b/docs/install.md index 42c868fc..b841e950 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,37 +30,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz - tar zxvf ntfy_2.12.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz + tar zxvf ntfy_2.13.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz - tar zxvf ntfy_2.12.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz + tar zxvf ntfy_2.13.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz - tar zxvf ntfy_2.12.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz + tar zxvf ntfy_2.13.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz - tar zxvf ntfy_2.12.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz + tar zxvf ntfy_2.13.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -110,7 +110,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -118,7 +118,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -126,7 +126,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -134,7 +134,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -144,28 +144,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz -tar zxvf ntfy_2.12.0_darwin_all.tar.gz -sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz +tar zxvf ntfy_2.13.0_darwin_all.tar.gz +sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -224,7 +224,7 @@ brew install ntfy ## Windows The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_windows_amd64.zip), +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). diff --git a/docs/releases.md b/docs/releases.md index 0877527e..484f3623 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,25 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +### ntfy server v2.13.0 +Released July 10, 2025 + +This is a relatively small release, mainly to support IPv6 and to add more sophisticated +proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us** +via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app). +ntfy will always remain open source. + +**Features:** + +* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) +* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) +* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) + +**Languages** + +* Update new languages from Weblate. Thanks to all the contributors! +* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app + ### ntfy server v2.12.0 Released May 29, 2025 @@ -1433,19 +1452,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.13.0 (UNRELEASED) - -**Features:** - -* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) -* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) -* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) - -**Languages** - -* Update new languages from Weblate. Thanks to all the contributors! -* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app - ### ntfy Android app v1.16.1 (UNRELEASED) **Features:** From fea0f301d2375a664ba52f50be7998efb4fa236e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 11 Jul 2025 13:19:31 +0200 Subject: [PATCH 38/87] Sprig funcs --- .goreleaser.yml | 84 +++++++++++++++++++++--------------------------- Makefile | 4 +-- docs/install.md | 60 +++++++++++++++++----------------- docs/releases.md | 29 +++++++++++------ 4 files changed, 89 insertions(+), 88 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index fa423a86..f0cf08f6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,76 +1,70 @@ +version: 2 before: hooks: - go mod download - go mod tidy builds: - - - id: ntfy_linux_amd64 + - id: ntfy_linux_amd64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [amd64] - - - id: ntfy_linux_armv6 + goos: [ linux ] + goarch: [ amd64 ] + - id: ntfy_linux_armv6 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [6] - - - id: ntfy_linux_armv7 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 6 ] + - id: ntfy_linux_armv7 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm] - goarm: [7] - - - id: ntfy_linux_arm64 + goos: [ linux ] + goarch: [ arm ] + goarm: [ 7 ] + - id: ntfy_linux_arm64 binary: ntfy env: - CGO_ENABLED=1 # required for go-sqlite3 - CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu - tags: [sqlite_omit_load_extension,osusergo,netgo] + tags: [ sqlite_omit_load_extension,osusergo,netgo ] ldflags: - "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [linux] - goarch: [arm64] - - - id: ntfy_windows_amd64 + goos: [ linux ] + goarch: [ arm64 ] + - id: ntfy_windows_amd64 binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [windows] - goarch: [amd64] - - - id: ntfy_darwin_all + goos: [ windows ] + goarch: [ amd64 ] + - id: ntfy_darwin_all binary: ntfy env: - CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3 - tags: [noserver] # don't include server files + tags: [ noserver ] # don't include server files ldflags: - "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}" - goos: [darwin] - goarch: [amd64, arm64] # will be combined to "universal binary" (see below) + goos: [ darwin ] + goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below) nfpms: - - - package_name: ntfy + - package_name: ntfy homepage: https://heckel.io/ntfy maintainer: Philipp C. Heckel description: Simple pub-sub notification service @@ -106,9 +100,8 @@ nfpms: preremove: "scripts/prerm.sh" postremove: "scripts/postrm.sh" archives: - - - id: ntfy_linux - builds: + - id: ntfy_linux + ids: - ntfy_linux_amd64 - ntfy_linux_armv6 - ntfy_linux_armv7 @@ -122,19 +115,17 @@ archives: - client/client.yml - client/ntfy-client.service - client/user/ntfy-client.service - - - id: ntfy_windows - builds: + - id: ntfy_windows + ids: - ntfy_windows_amd64 - format: zip + formats: [ zip ] wrap_in_directory: true files: - LICENSE - README.md - client/client.yml - - - id: ntfy_darwin - builds: + - id: ntfy_darwin + ids: - ntfy_darwin_all wrap_in_directory: true files: @@ -142,14 +133,13 @@ archives: - README.md - client/client.yml universal_binaries: - - - id: ntfy_darwin_all + - id: ntfy_darwin_all replace: true name_template: ntfy checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + version_template: "{{ .Tag }}-next" changelog: sort: asc filters: diff --git a/Makefile b/Makefile index 4355423e..575bb788 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ cli-deps-static-sites: touch server/docs/index.html server/site/app.html cli-deps-all: - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-deps-gcc-armv6-armv7: which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; } @@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check goreleaser release --clean release-snapshot: clean cli-deps docs web check - goreleaser release --snapshot --skip-publish --clean + goreleaser release --snapshot --clean release-checks: $(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-)) diff --git a/docs/install.md b/docs/install.md index 42c868fc..b841e950 100644 --- a/docs/install.md +++ b/docs/install.md @@ -30,37 +30,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz - tar zxvf ntfy_2.12.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.tar.gz + tar zxvf ntfy_2.13.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.13.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz - tar zxvf ntfy_2.12.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.tar.gz + tar zxvf ntfy_2.13.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz - tar zxvf ntfy_2.12.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.tar.gz + tar zxvf ntfy_2.13.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.13.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz - tar zxvf ntfy_2.12.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.tar.gz + tar zxvf ntfy_2.13.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.13.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.13.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -110,7 +110,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -118,7 +118,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -126,7 +126,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -134,7 +134,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -144,28 +144,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz -tar zxvf ntfy_2.12.0_darwin_all.tar.gz -sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_darwin_all.tar.gz > ntfy_2.13.0_darwin_all.tar.gz +tar zxvf ntfy_2.13.0_darwin_all.tar.gz +sudo cp -a ntfy_2.13.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.13.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -224,7 +224,7 @@ brew install ntfy ## Windows The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_windows_amd64.zip), +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.13.0/ntfy_2.13.0_windows_amd64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). diff --git a/docs/releases.md b/docs/releases.md index 5e18edab..fe91f580 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,25 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +### ntfy server v2.13.0 +Released July 10, 2025 + +This is a relatively small release, mainly to support IPv6 and to add more sophisticated +proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us** +via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app). +ntfy will always remain open source. + +**Features:** + +* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) +* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) +* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) + +**Languages** + +* Update new languages from Weblate. Thanks to all the contributors! +* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app + ### ntfy server v2.12.0 Released May 29, 2025 @@ -1433,20 +1452,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.13.0 (UNRELEASED) +### ntfy server v2.14.0 (UNRELEASED) **Features:** -* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4)) -* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha)) -* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn)) * You can now use a subset of [Sprig](https://github.com/Masterminds/sprig) functions in message/title templates ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) -**Languages** - -* Update new languages from Weblate. Thanks to all the contributors! -* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app - ### ntfy Android app v1.16.1 (UNRELEASED) **Features:** From 2a468493f92a17e626a3b3a48d87ccbc72d3848b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 13 Jul 2025 12:45:00 +0200 Subject: [PATCH 39/87] any --- util/sprig/date.go | 14 +++--- util/sprig/date_test.go | 34 ++++++------- util/sprig/defaults.go | 33 ++++++------- util/sprig/defaults_test.go | 16 +++--- util/sprig/dict.go | 39 +++++++-------- util/sprig/example_test.go | 2 +- util/sprig/functions.go | 74 +++++++-------------------- util/sprig/functions_test.go | 4 +- util/sprig/list.go | 96 ++++++++++++++++++------------------ util/sprig/numeric.go | 22 ++++----- util/sprig/numeric_test.go | 2 +- util/sprig/reflect.go | 10 ++-- util/sprig/strings.go | 18 +++---- util/sprig/strings_test.go | 16 +++--- util/sprig/url.go | 8 +-- util/sprig/url_test.go | 2 +- 16 files changed, 174 insertions(+), 216 deletions(-) diff --git a/util/sprig/date.go b/util/sprig/date.go index ed022dda..3fed04e9 100644 --- a/util/sprig/date.go +++ b/util/sprig/date.go @@ -10,19 +10,19 @@ import ( // Date can be a `time.Time` or an `int, int32, int64`. // In the later case, it is treated as seconds since UNIX // epoch. -func date(fmt string, date interface{}) string { +func date(fmt string, date any) string { return dateInZone(fmt, date, "Local") } -func htmlDate(date interface{}) string { +func htmlDate(date any) string { return dateInZone("2006-01-02", date, "Local") } -func htmlDateInZone(date interface{}, zone string) string { +func htmlDateInZone(date any, zone string) string { return dateInZone("2006-01-02", date, zone) } -func dateInZone(fmt string, date interface{}, zone string) string { +func dateInZone(fmt string, date any, zone string) string { var t time.Time switch date := date.(type) { default: @@ -63,7 +63,7 @@ func mustDateModify(fmt string, date time.Time) (time.Time, error) { return date.Add(d), nil } -func dateAgo(date interface{}) string { +func dateAgo(date any) string { var t time.Time switch date := date.(type) { @@ -81,7 +81,7 @@ func dateAgo(date interface{}) string { return duration.String() } -func duration(sec interface{}) string { +func duration(sec any) string { var n int64 switch value := sec.(type) { default: @@ -94,7 +94,7 @@ func duration(sec interface{}) string { return (time.Duration(n) * time.Second).String() } -func durationRound(duration interface{}) string { +func durationRound(duration any) string { var d time.Duration switch duration := duration.(type) { default: diff --git a/util/sprig/date_test.go b/util/sprig/date_test.go index be7ec9d9..3ebfa2be 100644 --- a/util/sprig/date_test.go +++ b/util/sprig/date_test.go @@ -15,15 +15,15 @@ func TestHtmlDate(t *testing.T) { func TestAgo(t *testing.T) { tpl := "{{ ago .Time }}" - if err := runtv(tpl, "2m5s", map[string]interface{}{"Time": time.Now().Add(-125 * time.Second)}); err != nil { + if err := runtv(tpl, "2m5s", map[string]any{"Time": time.Now().Add(-125 * time.Second)}); err != nil { t.Error(err) } - if err := runtv(tpl, "2h34m17s", map[string]interface{}{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil { + if err := runtv(tpl, "2h34m17s", map[string]any{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil { t.Error(err) } - if err := runtv(tpl, "-5s", map[string]interface{}{"Time": time.Now().Add(5 * time.Second)}); err != nil { + if err := runtv(tpl, "-5s", map[string]any{"Time": time.Now().Add(5 * time.Second)}); err != nil { t.Error(err) } } @@ -42,7 +42,7 @@ func TestUnixEpoch(t *testing.T) { } tpl := `{{unixEpoch .Time}}` - if err = runtv(tpl, "1560458379", map[string]interface{}{"Time": tm}); err != nil { + if err = runtv(tpl, "1560458379", map[string]any{"Time": tm}); err != nil { t.Error(err) } } @@ -55,66 +55,66 @@ func TestDateInZone(t *testing.T) { tpl := `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "UTC" }}` // Test time.Time input - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { t.Error(err) } // Test pointer to time.Time input - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": &tm}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": &tm}); err != nil { t.Error(err) } // Test no time input. This should be close enough to time.Now() we can test loc, _ := time.LoadLocation("UTC") - if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]interface{}{"Time": ""}); err != nil { + if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]any{"Time": ""}); err != nil { t.Error(err) } // Test unix timestamp as int64 - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int64(1560458379)}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int64(1560458379)}); err != nil { t.Error(err) } // Test unix timestamp as int32 - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int32(1560458379)}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int32(1560458379)}); err != nil { t.Error(err) } // Test unix timestamp as int - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": int(1560458379)}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int(1560458379)}); err != nil { t.Error(err) } // Test case of invalid timezone tpl = `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "foobar" }}` - if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]interface{}{"Time": tm}); err != nil { + if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { t.Error(err) } } func TestDuration(t *testing.T) { tpl := "{{ duration .Secs }}" - if err := runtv(tpl, "1m1s", map[string]interface{}{"Secs": "61"}); err != nil { + if err := runtv(tpl, "1m1s", map[string]any{"Secs": "61"}); err != nil { t.Error(err) } - if err := runtv(tpl, "1h0m0s", map[string]interface{}{"Secs": "3600"}); err != nil { + if err := runtv(tpl, "1h0m0s", map[string]any{"Secs": "3600"}); err != nil { t.Error(err) } // 1d2h3m4s but go is opinionated - if err := runtv(tpl, "26h3m4s", map[string]interface{}{"Secs": "93784"}); err != nil { + if err := runtv(tpl, "26h3m4s", map[string]any{"Secs": "93784"}); err != nil { t.Error(err) } } func TestDurationRound(t *testing.T) { tpl := "{{ durationRound .Time }}" - if err := runtv(tpl, "2h", map[string]interface{}{"Time": "2h5s"}); err != nil { + if err := runtv(tpl, "2h", map[string]any{"Time": "2h5s"}); err != nil { t.Error(err) } - if err := runtv(tpl, "1d", map[string]interface{}{"Time": "24h5s"}); err != nil { + if err := runtv(tpl, "1d", map[string]any{"Time": "24h5s"}); err != nil { t.Error(err) } - if err := runtv(tpl, "3mo", map[string]interface{}{"Time": "2400h5s"}); err != nil { + if err := runtv(tpl, "3mo", map[string]any{"Time": "2400h5s"}); err != nil { t.Error(err) } } diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 6a828a2a..7dcf7450 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -17,7 +17,7 @@ import ( // Structs are never considered unset. // // For everything else, including pointers, a nil value is unset. -func dfault(d interface{}, given ...interface{}) interface{} { +func dfault(d any, given ...any) any { if empty(given) || empty(given[0]) { return d @@ -26,7 +26,7 @@ func dfault(d interface{}, given ...interface{}) interface{} { } // empty returns true if the given value has the zero value for its type. -func empty(given interface{}) bool { +func empty(given any) bool { g := reflect.ValueOf(given) if !g.IsValid() { return true @@ -54,7 +54,7 @@ func empty(given interface{}) bool { } // coalesce returns the first non-empty value. -func coalesce(v ...interface{}) interface{} { +func coalesce(v ...any) any { for _, val := range v { if !empty(val) { return val @@ -65,7 +65,7 @@ func coalesce(v ...interface{}) interface{} { // all returns true if empty(x) is false for all values x in the list. // If the list is empty, return true. -func all(v ...interface{}) bool { +func all(v ...any) bool { for _, val := range v { if empty(val) { return false @@ -74,9 +74,9 @@ func all(v ...interface{}) bool { return true } -// any returns true if empty(x) is false for any x in the list. +// anyNonEmpty returns true if empty(x) is false for anyNonEmpty x in the list. // If the list is empty, return false. -func any(v ...interface{}) bool { +func anyNonEmpty(v ...any) bool { for _, val := range v { if !empty(val) { return true @@ -86,25 +86,25 @@ func any(v ...interface{}) bool { } // fromJSON decodes JSON into a structured value, ignoring errors. -func fromJSON(v string) interface{} { +func fromJSON(v string) any { output, _ := mustFromJSON(v) return output } // mustFromJSON decodes JSON into a structured value, returning errors. -func mustFromJSON(v string) (interface{}, error) { - var output interface{} +func mustFromJSON(v string) (any, error) { + var output any err := json.Unmarshal([]byte(v), &output) return output, err } // toJSON encodes an item into a JSON string -func toJSON(v interface{}) string { +func toJSON(v any) string { output, _ := json.Marshal(v) return string(output) } -func mustToJSON(v interface{}) (string, error) { +func mustToJSON(v any) (string, error) { output, err := json.Marshal(v) if err != nil { return "", err @@ -113,12 +113,12 @@ func mustToJSON(v interface{}) (string, error) { } // toPrettyJSON encodes an item into a pretty (indented) JSON string -func toPrettyJSON(v interface{}) string { +func toPrettyJSON(v any) string { output, _ := json.MarshalIndent(v, "", " ") return string(output) } -func mustToPrettyJSON(v interface{}) (string, error) { +func mustToPrettyJSON(v any) (string, error) { output, err := json.MarshalIndent(v, "", " ") if err != nil { return "", err @@ -127,7 +127,7 @@ func mustToPrettyJSON(v interface{}) (string, error) { } // toRawJSON encodes an item into a JSON string with no escaping of HTML characters. -func toRawJSON(v interface{}) string { +func toRawJSON(v any) string { output, err := mustToRawJSON(v) if err != nil { panic(err) @@ -136,7 +136,7 @@ func toRawJSON(v interface{}) string { } // mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. -func mustToRawJSON(v interface{}) (string, error) { +func mustToRawJSON(v any) (string, error) { buf := new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) @@ -148,10 +148,9 @@ func mustToRawJSON(v interface{}) (string, error) { } // ternary returns the first value if the last value is true, otherwise returns the second value. -func ternary(vt interface{}, vf interface{}, v bool) interface{} { +func ternary(vt any, vf any, v bool) any { if v { return vt } - return vf } diff --git a/util/sprig/defaults_test.go b/util/sprig/defaults_test.go index eb7e35b4..f67c9cd9 100644 --- a/util/sprig/defaults_test.go +++ b/util/sprig/defaults_test.go @@ -53,7 +53,7 @@ func TestEmpty(t *testing.T) { t.Error(err) } - dict := map[string]interface{}{"top": map[string]interface{}{}} + dict := map[string]any{"top": map[string]any{}} tpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}` if err := runtv(tpl, "1", dict); err != nil { t.Error(err) @@ -77,7 +77,7 @@ func TestCoalesce(t *testing.T) { assert.NoError(t, runt(tpl, expect)) } - dict := map[string]interface{}{"top": map[string]interface{}{}} + dict := map[string]any{"top": map[string]any{}} tpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` if err := runtv(tpl, "airplane", dict); err != nil { t.Error(err) @@ -97,7 +97,7 @@ func TestAll(t *testing.T) { assert.NoError(t, runt(tpl, expect)) } - dict := map[string]interface{}{"top": map[string]interface{}{}} + dict := map[string]any{"top": map[string]any{}} tpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` if err := runtv(tpl, "false", dict); err != nil { t.Error(err) @@ -117,7 +117,7 @@ func TestAny(t *testing.T) { assert.NoError(t, runt(tpl, expect)) } - dict := map[string]interface{}{"top": map[string]interface{}{}} + dict := map[string]any{"top": map[string]any{}} tpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar "airplane"}}` if err := runtv(tpl, "true", dict); err != nil { t.Error(err) @@ -125,7 +125,7 @@ func TestAny(t *testing.T) { } func TestFromJSON(t *testing.T) { - dict := map[string]interface{}{"Input": `{"foo": 55}`} + dict := map[string]any{"Input": `{"foo": 55}`} tpl := `{{.Input | fromJSON}}` expected := `map[foo:55]` @@ -141,7 +141,7 @@ func TestFromJSON(t *testing.T) { } func TestToJSON(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}} tpl := `{{.Top | toJSON}}` expected := `{"bool":true,"number":42,"string":"test"}` @@ -151,7 +151,7 @@ func TestToJSON(t *testing.T) { } func TestToPrettyJSON(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42}} + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}} tpl := `{{.Top | toPrettyJSON}}` expected := `{ "bool": true, @@ -164,7 +164,7 @@ func TestToPrettyJSON(t *testing.T) { } func TestToRawJSON(t *testing.T) { - dict := map[string]interface{}{"Top": map[string]interface{}{"bool": true, "string": "test", "number": 42, "html": ""}} + dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42, "html": ""}} tpl := `{{.Top | toRawJSON}}` expected := `{"bool":true,"html":"","number":42,"string":"test"}` diff --git a/util/sprig/dict.go b/util/sprig/dict.go index fd2dd711..97182a97 100644 --- a/util/sprig/dict.go +++ b/util/sprig/dict.go @@ -1,29 +1,29 @@ package sprig -func get(d map[string]interface{}, key string) interface{} { +func get(d map[string]any, key string) any { if val, ok := d[key]; ok { return val } return "" } -func set(d map[string]interface{}, key string, value interface{}) map[string]interface{} { +func set(d map[string]any, key string, value any) map[string]any { d[key] = value return d } -func unset(d map[string]interface{}, key string) map[string]interface{} { +func unset(d map[string]any, key string) map[string]any { delete(d, key) return d } -func hasKey(d map[string]interface{}, key string) bool { +func hasKey(d map[string]any, key string) bool { _, ok := d[key] return ok } -func pluck(key string, d ...map[string]interface{}) []interface{} { - res := []interface{}{} +func pluck(key string, d ...map[string]any) []any { + var res []any for _, dict := range d { if val, ok := dict[key]; ok { res = append(res, val) @@ -32,7 +32,7 @@ func pluck(key string, d ...map[string]interface{}) []interface{} { return res } -func keys(dicts ...map[string]interface{}) []string { +func keys(dicts ...map[string]any) []string { k := []string{} for _, dict := range dicts { for key := range dict { @@ -42,8 +42,8 @@ func keys(dicts ...map[string]interface{}) []string { return k } -func pick(dict map[string]interface{}, keys ...string) map[string]interface{} { - res := map[string]interface{}{} +func pick(dict map[string]any, keys ...string) map[string]any { + res := map[string]any{} for _, k := range keys { if v, ok := dict[k]; ok { res[k] = v @@ -52,8 +52,8 @@ func pick(dict map[string]interface{}, keys ...string) map[string]interface{} { return res } -func omit(dict map[string]interface{}, keys ...string) map[string]interface{} { - res := map[string]interface{}{} +func omit(dict map[string]any, keys ...string) map[string]any { + res := map[string]any{} omit := make(map[string]bool, len(keys)) for _, k := range keys { @@ -68,8 +68,8 @@ func omit(dict map[string]interface{}, keys ...string) map[string]interface{} { return res } -func dict(v ...interface{}) map[string]interface{} { - dict := map[string]interface{}{} +func dict(v ...any) map[string]any { + dict := map[string]any{} lenv := len(v) for i := 0; i < lenv; i += 2 { key := strval(v[i]) @@ -82,20 +82,19 @@ func dict(v ...interface{}) map[string]interface{} { return dict } -func values(dict map[string]interface{}) []interface{} { - values := []interface{}{} +func values(dict map[string]any) []any { + var values []any for _, value := range dict { values = append(values, value) } - return values } -func dig(ps ...interface{}) (interface{}, error) { +func dig(ps ...any) (any, error) { if len(ps) < 3 { panic("dig needs at least three arguments") } - dict := ps[len(ps)-1].(map[string]interface{}) + dict := ps[len(ps)-1].(map[string]any) def := ps[len(ps)-2] ks := make([]string, len(ps)-2) for i := 0; i < len(ks); i++ { @@ -105,7 +104,7 @@ func dig(ps ...interface{}) (interface{}, error) { return digFromDict(dict, def, ks) } -func digFromDict(dict map[string]interface{}, d interface{}, ks []string) (interface{}, error) { +func digFromDict(dict map[string]any, d any, ks []string) (any, error) { k, ns := ks[0], ks[1:] step, has := dict[k] if !has { @@ -114,5 +113,5 @@ func digFromDict(dict map[string]interface{}, d interface{}, ks []string) (inter if len(ns) == 0 { return step, nil } - return digFromDict(step.(map[string]interface{}), d, ns) + return digFromDict(step.(map[string]any), d, ns) } diff --git a/util/sprig/example_test.go b/util/sprig/example_test.go index 2d7696bf..2f1b74c8 100644 --- a/util/sprig/example_test.go +++ b/util/sprig/example_test.go @@ -8,7 +8,7 @@ import ( func Example() { // Set up variables and template. - vars := map[string]interface{}{"Name": " John Jacob Jingleheimer Schmidt "} + vars := map[string]any{"Name": " John Jacob Jingleheimer Schmidt "} tpl := `Hello {{.Name | trim | lower}}` // Get the Sprig function map. diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 3ea46924..68ef516d 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -24,68 +24,26 @@ func FuncMap() template.FuncMap { return HTMLFuncMap() } -// HermeticTxtFuncMap returns a 'text/template'.FuncMap with only repeatable functions. -func HermeticTxtFuncMap() ttemplate.FuncMap { - r := TxtFuncMap() - for _, name := range nonhermeticFunctions { - delete(r, name) - } - return r -} - -// HermeticHTMLFuncMap returns an 'html/template'.Funcmap with only repeatable functions. -func HermeticHTMLFuncMap() template.FuncMap { - r := HTMLFuncMap() - for _, name := range nonhermeticFunctions { - delete(r, name) - } - return r -} - // TxtFuncMap returns a 'text/template'.FuncMap func TxtFuncMap() ttemplate.FuncMap { - return ttemplate.FuncMap(GenericFuncMap()) + return GenericFuncMap() } // HTMLFuncMap returns an 'html/template'.Funcmap func HTMLFuncMap() template.FuncMap { - return template.FuncMap(GenericFuncMap()) + return GenericFuncMap() } -// GenericFuncMap returns a copy of the basic function map as a map[string]interface{}. -func GenericFuncMap() map[string]interface{} { - gfm := make(map[string]interface{}, len(genericMap)) +// GenericFuncMap returns a copy of the basic function map as a map[string]any. +func GenericFuncMap() map[string]any { + gfm := make(map[string]any, len(genericMap)) for k, v := range genericMap { gfm[k] = v } return gfm } -// These functions are not guaranteed to evaluate to the same result for given input, because they -// refer to the environment or global state. -var nonhermeticFunctions = []string{ - // Date functions - "date", - "date_in_zone", - "date_modify", - "now", - "htmlDate", - "htmlDateInZone", - "dateInZone", - "dateModify", - - // Strings - "randAlphaNum", - "randAlpha", - "randAscii", - "randNumeric", - "randBytes", - "uuidv4", -} - -var genericMap = map[string]interface{}{ - "hello": func() string { return "Hello!" }, - +var genericMap = map[string]any{ // Date functions "ago": dateAgo, "date": date, @@ -157,18 +115,18 @@ var genericMap = map[string]interface{}{ "untilStep": untilStep, // VERY basic arithmetic. - "add1": func(i interface{}) int64 { return toInt64(i) + 1 }, - "add": func(i ...interface{}) int64 { + "add1": func(i any) int64 { return toInt64(i) + 1 }, + "add": func(i ...any) int64 { var a int64 = 0 for _, b := range i { a += toInt64(b) } return a }, - "sub": func(a, b interface{}) int64 { return toInt64(a) - toInt64(b) }, - "div": func(a, b interface{}) int64 { return toInt64(a) / toInt64(b) }, - "mod": func(a, b interface{}) int64 { return toInt64(a) % toInt64(b) }, - "mul": func(a interface{}, v ...interface{}) int64 { + "sub": func(a, b any) int64 { return toInt64(a) - toInt64(b) }, + "div": func(a, b any) int64 { return toInt64(a) / toInt64(b) }, + "mod": func(a, b any) int64 { return toInt64(a) % toInt64(b) }, + "mul": func(a any, v ...any) int64 { val := toInt64(a) for _, b := range v { val = val * toInt64(b) @@ -195,7 +153,7 @@ var genericMap = map[string]interface{}{ "empty": empty, "coalesce": coalesce, "all": all, - "any": any, + "any": anyNonEmpty, "compact": compact, "mustCompact": mustCompact, "fromJSON": fromJSON, @@ -250,8 +208,10 @@ var genericMap = map[string]interface{}{ "omit": omit, "values": values, - "append": push, "push": push, - "mustAppend": mustPush, "mustPush": mustPush, + "append": push, + "push": push, + "mustAppend": mustPush, + "mustPush": mustPush, "prepend": prepend, "mustPrepend": mustPrepend, "first": first, diff --git a/util/sprig/functions_test.go b/util/sprig/functions_test.go index b7bc01f4..e5989b98 100644 --- a/util/sprig/functions_test.go +++ b/util/sprig/functions_test.go @@ -43,7 +43,7 @@ func runt(tpl, expect string) error { // runtv takes a template, and expected return, and values for substitution. // // It runs the template and verifies that the output is an exact match. -func runtv(tpl, expect string, vars interface{}) error { +func runtv(tpl, expect string, vars any) error { fmap := TxtFuncMap() t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) var b bytes.Buffer @@ -58,7 +58,7 @@ func runtv(tpl, expect string, vars interface{}) error { } // runRaw runs a template with the given variables and returns the result. -func runRaw(tpl string, vars interface{}) (string, error) { +func runRaw(tpl string, vars any) (string, error) { fmap := TxtFuncMap() t := template.Must(template.New("test").Funcs(fmap).Parse(tpl)) var b bytes.Buffer diff --git a/util/sprig/list.go b/util/sprig/list.go index f4e95dda..138ecfa5 100644 --- a/util/sprig/list.go +++ b/util/sprig/list.go @@ -8,14 +8,14 @@ import ( ) // Reflection is used in these functions so that slices and arrays of strings, -// ints, and other types not implementing []interface{} can be worked with. +// ints, and other types not implementing []any can be worked with. // For example, this is useful if you need to work on the output of regexs. -func list(v ...interface{}) []interface{} { +func list(v ...any) []any { return v } -func push(list interface{}, v interface{}) []interface{} { +func push(list any, v any) []any { l, err := mustPush(list, v) if err != nil { panic(err) @@ -24,14 +24,14 @@ func push(list interface{}, v interface{}) []interface{} { return l } -func mustPush(list interface{}, v interface{}) ([]interface{}, error) { +func mustPush(list any, v any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) l := l2.Len() - nl := make([]interface{}, l) + nl := make([]any, l) for i := 0; i < l; i++ { nl[i] = l2.Index(i).Interface() } @@ -43,7 +43,7 @@ func mustPush(list interface{}, v interface{}) ([]interface{}, error) { } } -func prepend(list interface{}, v interface{}) []interface{} { +func prepend(list any, v any) []any { l, err := mustPrepend(list, v) if err != nil { panic(err) @@ -52,8 +52,8 @@ func prepend(list interface{}, v interface{}) []interface{} { return l } -func mustPrepend(list interface{}, v interface{}) ([]interface{}, error) { - //return append([]interface{}{v}, list...) +func mustPrepend(list any, v any) ([]any, error) { + //return append([]any{v}, list...) tp := reflect.TypeOf(list).Kind() switch tp { @@ -61,19 +61,19 @@ func mustPrepend(list interface{}, v interface{}) ([]interface{}, error) { l2 := reflect.ValueOf(list) l := l2.Len() - nl := make([]interface{}, l) + nl := make([]any, l) for i := 0; i < l; i++ { nl[i] = l2.Index(i).Interface() } - return append([]interface{}{v}, nl...), nil + return append([]any{v}, nl...), nil default: return nil, fmt.Errorf("cannot prepend on type %s", tp) } } -func chunk(size int, list interface{}) [][]interface{} { +func chunk(size int, list any) [][]any { l, err := mustChunk(size, list) if err != nil { panic(err) @@ -82,7 +82,7 @@ func chunk(size int, list interface{}) [][]interface{} { return l } -func mustChunk(size int, list interface{}) ([][]interface{}, error) { +func mustChunk(size int, list any) ([][]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -91,7 +91,7 @@ func mustChunk(size int, list interface{}) ([][]interface{}, error) { l := l2.Len() cs := int(math.Floor(float64(l-1)/float64(size)) + 1) - nl := make([][]interface{}, cs) + nl := make([][]any, cs) for i := 0; i < cs; i++ { clen := size @@ -102,7 +102,7 @@ func mustChunk(size int, list interface{}) ([][]interface{}, error) { } } - nl[i] = make([]interface{}, clen) + nl[i] = make([]any, clen) for j := 0; j < clen; j++ { ix := i*size + j @@ -117,7 +117,7 @@ func mustChunk(size int, list interface{}) ([][]interface{}, error) { } } -func last(list interface{}) interface{} { +func last(list any) any { l, err := mustLast(list) if err != nil { panic(err) @@ -126,7 +126,7 @@ func last(list interface{}) interface{} { return l } -func mustLast(list interface{}) (interface{}, error) { +func mustLast(list any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -143,7 +143,7 @@ func mustLast(list interface{}) (interface{}, error) { } } -func first(list interface{}) interface{} { +func first(list any) any { l, err := mustFirst(list) if err != nil { panic(err) @@ -152,7 +152,7 @@ func first(list interface{}) interface{} { return l } -func mustFirst(list interface{}) (interface{}, error) { +func mustFirst(list any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -169,7 +169,7 @@ func mustFirst(list interface{}) (interface{}, error) { } } -func rest(list interface{}) []interface{} { +func rest(list any) []any { l, err := mustRest(list) if err != nil { panic(err) @@ -178,7 +178,7 @@ func rest(list interface{}) []interface{} { return l } -func mustRest(list interface{}) ([]interface{}, error) { +func mustRest(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -189,7 +189,7 @@ func mustRest(list interface{}) ([]interface{}, error) { return nil, nil } - nl := make([]interface{}, l-1) + nl := make([]any, l-1) for i := 1; i < l; i++ { nl[i-1] = l2.Index(i).Interface() } @@ -200,7 +200,7 @@ func mustRest(list interface{}) ([]interface{}, error) { } } -func initial(list interface{}) []interface{} { +func initial(list any) []any { l, err := mustInitial(list) if err != nil { panic(err) @@ -209,7 +209,7 @@ func initial(list interface{}) []interface{} { return l } -func mustInitial(list interface{}) ([]interface{}, error) { +func mustInitial(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -220,7 +220,7 @@ func mustInitial(list interface{}) ([]interface{}, error) { return nil, nil } - nl := make([]interface{}, l-1) + nl := make([]any, l-1) for i := 0; i < l-1; i++ { nl[i] = l2.Index(i).Interface() } @@ -231,7 +231,7 @@ func mustInitial(list interface{}) ([]interface{}, error) { } } -func sortAlpha(list interface{}) []string { +func sortAlpha(list any) []string { k := reflect.Indirect(reflect.ValueOf(list)).Kind() switch k { case reflect.Slice, reflect.Array: @@ -243,7 +243,7 @@ func sortAlpha(list interface{}) []string { return []string{strval(list)} } -func reverse(v interface{}) []interface{} { +func reverse(v any) []any { l, err := mustReverse(v) if err != nil { panic(err) @@ -252,7 +252,7 @@ func reverse(v interface{}) []interface{} { return l } -func mustReverse(v interface{}) ([]interface{}, error) { +func mustReverse(v any) ([]any, error) { tp := reflect.TypeOf(v).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -260,7 +260,7 @@ func mustReverse(v interface{}) ([]interface{}, error) { l := l2.Len() // We do not sort in place because the incoming array should not be altered. - nl := make([]interface{}, l) + nl := make([]any, l) for i := 0; i < l; i++ { nl[l-i-1] = l2.Index(i).Interface() } @@ -271,7 +271,7 @@ func mustReverse(v interface{}) ([]interface{}, error) { } } -func compact(list interface{}) []interface{} { +func compact(list any) []any { l, err := mustCompact(list) if err != nil { panic(err) @@ -280,15 +280,15 @@ func compact(list interface{}) []interface{} { return l } -func mustCompact(list interface{}) ([]interface{}, error) { +func mustCompact(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) l := l2.Len() - nl := []interface{}{} - var item interface{} + nl := []any{} + var item any for i := 0; i < l; i++ { item = l2.Index(i).Interface() if !empty(item) { @@ -302,7 +302,7 @@ func mustCompact(list interface{}) ([]interface{}, error) { } } -func uniq(list interface{}) []interface{} { +func uniq(list any) []any { l, err := mustUniq(list) if err != nil { panic(err) @@ -311,15 +311,15 @@ func uniq(list interface{}) []interface{} { return l } -func mustUniq(list interface{}) ([]interface{}, error) { +func mustUniq(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) l := l2.Len() - dest := []interface{}{} - var item interface{} + dest := []any{} + var item any for i := 0; i < l; i++ { item = l2.Index(i).Interface() if !inList(dest, item) { @@ -333,7 +333,7 @@ func mustUniq(list interface{}) ([]interface{}, error) { } } -func inList(haystack []interface{}, needle interface{}) bool { +func inList(haystack []any, needle any) bool { for _, h := range haystack { if reflect.DeepEqual(needle, h) { return true @@ -342,7 +342,7 @@ func inList(haystack []interface{}, needle interface{}) bool { return false } -func without(list interface{}, omit ...interface{}) []interface{} { +func without(list any, omit ...any) []any { l, err := mustWithout(list, omit...) if err != nil { panic(err) @@ -351,15 +351,15 @@ func without(list interface{}, omit ...interface{}) []interface{} { return l } -func mustWithout(list interface{}, omit ...interface{}) ([]interface{}, error) { +func mustWithout(list any, omit ...any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) l := l2.Len() - res := []interface{}{} - var item interface{} + res := []any{} + var item any for i := 0; i < l; i++ { item = l2.Index(i).Interface() if !inList(omit, item) { @@ -373,7 +373,7 @@ func mustWithout(list interface{}, omit ...interface{}) ([]interface{}, error) { } } -func has(needle interface{}, haystack interface{}) bool { +func has(needle any, haystack any) bool { l, err := mustHas(needle, haystack) if err != nil { panic(err) @@ -382,7 +382,7 @@ func has(needle interface{}, haystack interface{}) bool { return l } -func mustHas(needle interface{}, haystack interface{}) (bool, error) { +func mustHas(needle any, haystack any) (bool, error) { if haystack == nil { return false, nil } @@ -390,7 +390,7 @@ func mustHas(needle interface{}, haystack interface{}) (bool, error) { switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(haystack) - var item interface{} + var item any l := l2.Len() for i := 0; i < l; i++ { item = l2.Index(i).Interface() @@ -410,7 +410,7 @@ func mustHas(needle interface{}, haystack interface{}) (bool, error) { // slice $list 0 3 -> list[0:3] = list[:3] // slice $list 3 5 -> list[3:5] // slice $list 3 -> list[3:5] = list[3:] -func slice(list interface{}, indices ...interface{}) interface{} { +func slice(list any, indices ...any) any { l, err := mustSlice(list, indices...) if err != nil { panic(err) @@ -419,7 +419,7 @@ func slice(list interface{}, indices ...interface{}) interface{} { return l } -func mustSlice(list interface{}, indices ...interface{}) (interface{}, error) { +func mustSlice(list any, indices ...any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: @@ -446,8 +446,8 @@ func mustSlice(list interface{}, indices ...interface{}) (interface{}, error) { } } -func concat(lists ...interface{}) interface{} { - var res []interface{} +func concat(lists ...any) any { + var res []any for _, list := range lists { tp := reflect.TypeOf(list).Kind() switch tp { diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go index 0b23cd21..e41f61f5 100644 --- a/util/sprig/numeric.go +++ b/util/sprig/numeric.go @@ -9,7 +9,7 @@ import ( ) // toFloat64 converts 64-bit floats -func toFloat64(v interface{}) float64 { +func toFloat64(v any) float64 { if str, ok := v.(string); ok { iv, err := strconv.ParseFloat(str, 64) if err != nil { @@ -38,13 +38,13 @@ func toFloat64(v interface{}) float64 { } } -func toInt(v interface{}) int { +func toInt(v any) int { // It's not optimal. But I don't want duplicate toInt64 code. return int(toInt64(v)) } // toInt64 converts integer types to 64-bit integers -func toInt64(v interface{}) int64 { +func toInt64(v any) int64 { if str, ok := v.(string); ok { iv, err := strconv.ParseInt(str, 10, 64) if err != nil { @@ -78,7 +78,7 @@ func toInt64(v interface{}) int64 { } } -func max(a interface{}, i ...interface{}) int64 { +func max(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { bb := toInt64(b) @@ -89,7 +89,7 @@ func max(a interface{}, i ...interface{}) int64 { return aa } -func maxf(a interface{}, i ...interface{}) float64 { +func maxf(a any, i ...any) float64 { aa := toFloat64(a) for _, b := range i { bb := toFloat64(b) @@ -98,7 +98,7 @@ func maxf(a interface{}, i ...interface{}) float64 { return aa } -func min(a interface{}, i ...interface{}) int64 { +func min(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { bb := toInt64(b) @@ -109,7 +109,7 @@ func min(a interface{}, i ...interface{}) int64 { return aa } -func minf(a interface{}, i ...interface{}) float64 { +func minf(a any, i ...any) float64 { aa := toFloat64(a) for _, b := range i { bb := toFloat64(b) @@ -148,17 +148,17 @@ func untilStep(start, stop, step int) []int { return v } -func floor(a interface{}) float64 { +func floor(a any) float64 { aa := toFloat64(a) return math.Floor(aa) } -func ceil(a interface{}) float64 { +func ceil(a any) float64 { aa := toFloat64(a) return math.Ceil(aa) } -func round(a interface{}, p int, rOpt ...float64) float64 { +func round(a any, p int, rOpt ...float64) float64 { roundOn := .5 if len(rOpt) > 0 { roundOn = rOpt[0] @@ -179,7 +179,7 @@ func round(a interface{}, p int, rOpt ...float64) float64 { } // converts unix octal to decimal -func toDecimal(v interface{}) int64 { +func toDecimal(v any) int64 { result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) if err != nil { return 0 diff --git a/util/sprig/numeric_test.go b/util/sprig/numeric_test.go index 573873d8..63310c52 100644 --- a/util/sprig/numeric_test.go +++ b/util/sprig/numeric_test.go @@ -192,7 +192,7 @@ func TestToInt(t *testing.T) { } func TestToDecimal(t *testing.T) { - tests := map[interface{}]int64{ + tests := map[any]int64{ "777": 511, 777: 511, 770: 504, diff --git a/util/sprig/reflect.go b/util/sprig/reflect.go index 8a65c132..5e37f64f 100644 --- a/util/sprig/reflect.go +++ b/util/sprig/reflect.go @@ -6,23 +6,23 @@ import ( ) // typeIs returns true if the src is the type named in target. -func typeIs(target string, src interface{}) bool { +func typeIs(target string, src any) bool { return target == typeOf(src) } -func typeIsLike(target string, src interface{}) bool { +func typeIsLike(target string, src any) bool { t := typeOf(src) return target == t || "*"+target == t } -func typeOf(src interface{}) string { +func typeOf(src any) string { return fmt.Sprintf("%T", src) } -func kindIs(target string, src interface{}) bool { +func kindIs(target string, src any) bool { return target == kindOf(src) } -func kindOf(src interface{}) string { +func kindOf(src any) string { return reflect.ValueOf(src).Kind().String() } diff --git a/util/sprig/strings.go b/util/sprig/strings.go index 3c62d6b6..911aa6f4 100644 --- a/util/sprig/strings.go +++ b/util/sprig/strings.go @@ -33,7 +33,7 @@ func base32decode(v string) string { return string(data) } -func quote(str ...interface{}) string { +func quote(str ...any) string { out := make([]string, 0, len(str)) for _, s := range str { if s != nil { @@ -43,7 +43,7 @@ func quote(str ...interface{}) string { return strings.Join(out, " ") } -func squote(str ...interface{}) string { +func squote(str ...any) string { out := make([]string, 0, len(str)) for _, s := range str { if s != nil { @@ -53,7 +53,7 @@ func squote(str ...interface{}) string { return strings.Join(out, " ") } -func cat(v ...interface{}) string { +func cat(v ...any) string { v = removeNilElements(v) r := strings.TrimSpace(strings.Repeat("%v ", len(v))) return fmt.Sprintf(r, v...) @@ -79,11 +79,11 @@ func plural(one, many string, count int) string { return many } -func strslice(v interface{}) []string { +func strslice(v any) []string { switch v := v.(type) { case []string: return v - case []interface{}: + case []any: b := make([]string, 0, len(v)) for _, s := range v { if s != nil { @@ -114,8 +114,8 @@ func strslice(v interface{}) []string { } } -func removeNilElements(v []interface{}) []interface{} { - newSlice := make([]interface{}, 0, len(v)) +func removeNilElements(v []any) []any { + newSlice := make([]any, 0, len(v)) for _, i := range v { if i != nil { newSlice = append(newSlice, i) @@ -124,7 +124,7 @@ func removeNilElements(v []interface{}) []interface{} { return newSlice } -func strval(v interface{}) string { +func strval(v any) string { switch v := v.(type) { case string: return v @@ -149,7 +149,7 @@ func trunc(c int, s string) string { return s } -func join(sep string, v interface{}) string { +func join(sep string, v any) string { return strings.Join(strslice(v), sep) } diff --git a/util/sprig/strings_test.go b/util/sprig/strings_test.go index 38c96c4e..1e91d9b2 100644 --- a/util/sprig/strings_test.go +++ b/util/sprig/strings_test.go @@ -56,7 +56,7 @@ func TestQuote(t *testing.T) { t.Error(err) } tpl = `{{ .value | quote }}` - values := map[string]interface{}{"value": nil} + values := map[string]any{"value": nil} if err := runtv(tpl, ``, values); err != nil { t.Error(err) } @@ -71,7 +71,7 @@ func TestSquote(t *testing.T) { t.Error(err) } tpl = `{{ .value | squote }}` - values := map[string]interface{}{"value": nil} + values := map[string]any{"value": nil} if err := runtv(tpl, ``, values); err != nil { t.Error(err) } @@ -128,7 +128,7 @@ func TestToStrings(t *testing.T) { tpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}` assert.NoError(t, runt(tpl, "string")) tpl = `{{ list 1 .value 2 | toStrings }}` - values := map[string]interface{}{"value": nil} + values := map[string]any{"value": nil} if err := runtv(tpl, `[1 2]`, values); err != nil { t.Error(err) } @@ -137,10 +137,10 @@ func TestToStrings(t *testing.T) { func TestJoin(t *testing.T) { assert.NoError(t, runt(`{{ tuple "a" "b" "c" | join "-" }}`, "a-b-c")) assert.NoError(t, runt(`{{ tuple 1 2 3 | join "-" }}`, "1-2-3")) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]interface{}{"V": []string{"a", "b", "c"}})) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]interface{}{"V": "abc"})) - assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]interface{}{"V": []int{1, 2, 3}})) - assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]interface{}{"value": []interface{}{"1", nil, "2"}})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]any{"V": []string{"a", "b", "c"}})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]any{"V": "abc"})) + assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]any{"V": []int{1, 2, 3}})) + assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]any{"value": []any{"1", nil, "2"}})) } func TestSortAlpha(t *testing.T) { @@ -194,7 +194,7 @@ func TestCat(t *testing.T) { t.Error(err) } tpl = `{{ .value | cat "a" "b"}}` - values := map[string]interface{}{"value": nil} + values := map[string]any{"value": nil} if err := runtv(tpl, "a b", values); err != nil { t.Error(err) } diff --git a/util/sprig/url.go b/util/sprig/url.go index b8e120e1..00826706 100644 --- a/util/sprig/url.go +++ b/util/sprig/url.go @@ -6,7 +6,7 @@ import ( "reflect" ) -func dictGetOrEmpty(dict map[string]interface{}, key string) string { +func dictGetOrEmpty(dict map[string]any, key string) string { value, ok := dict[key] if !ok { return "" @@ -19,8 +19,8 @@ func dictGetOrEmpty(dict map[string]interface{}, key string) string { } // parses given URL to return dict object -func urlParse(v string) map[string]interface{} { - dict := map[string]interface{}{} +func urlParse(v string) map[string]any { + dict := map[string]any{} parsedURL, err := url.Parse(v) if err != nil { panic(fmt.Sprintf("unable to parse url: %s", err)) @@ -42,7 +42,7 @@ func urlParse(v string) map[string]interface{} { } // join given dict to URL string -func urlJoin(d map[string]interface{}) string { +func urlJoin(d map[string]any) string { resURL := url.URL{ Scheme: dictGetOrEmpty(d, "scheme"), Host: dictGetOrEmpty(d, "host"), diff --git a/util/sprig/url_test.go b/util/sprig/url_test.go index f9c00b17..16d457a7 100644 --- a/util/sprig/url_test.go +++ b/util/sprig/url_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -var urlTests = map[string]map[string]interface{}{ +var urlTests = map[string]map[string]any{ "proto://auth@host:80/path?query#fragment": { "fragment": "fragment", "host": "host:80", From 8bf4727a1c7f0ace5fcbb943cc1216badc58709b Mon Sep 17 00:00:00 2001 From: Kristopher Paulsen Date: Sun, 13 Jul 2025 09:50:06 -0400 Subject: [PATCH 40/87] Missing double quote, sneaky little bugger --- docs/subscribe/cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subscribe/cli.md b/docs/subscribe/cli.md index 78e160c8..36388c71 100644 --- a/docs/subscribe/cli.md +++ b/docs/subscribe/cli.md @@ -156,7 +156,7 @@ environment variables. Here are a few examples: ``` ntfy sub mytopic 'notify-send "$m"' ntfy sub topic1 /my/script.sh -ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p' +ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p"' ```

From 93e14b73bbab56f688099cde385969195e8df132 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 16 Jul 2025 10:01:59 +0200 Subject: [PATCH 41/87] Tempalte dir --- cmd/serve.go | 3 +++ server/config.go | 2 ++ server/server.go | 60 +++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef4d98d5..d762a7c6 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -107,6 +107,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-directory", Aliases: []string{"template_directory"}, EnvVars: []string{"NTFY_TEMPLATE_DIRECTORY"}, Usage: "directory to load named templates from"}), ) var cmdServe = &cli.Command{ @@ -205,6 +206,7 @@ func execServe(c *cli.Context) error { metricsListenHTTP := c.String("metrics-listen-http") enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" profileListenHTTP := c.String("profile-listen-http") + templateDirectory := c.String("template-directory") // Convert durations cacheDuration, err := util.ParseDuration(cacheDurationStr) @@ -461,6 +463,7 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration + conf.TemplateDirectory = templateDirectory conf.Version = c.App.Version // Set up hot-reloading of config diff --git a/server/config.go b/server/config.go index 59b11c16..46848fe5 100644 --- a/server/config.go +++ b/server/config.go @@ -167,6 +167,7 @@ type Config struct { WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration Version string // injected by App + TemplateDirectory string // Directory to load named templates from } // NewConfig instantiates a default new server config @@ -257,5 +258,6 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, + TemplateDirectory: "", } } diff --git a/server/server.go b/server/server.go index 7e5fbb94..51c56f3e 100644 --- a/server/server.go +++ b/server/server.go @@ -62,6 +62,7 @@ type Server struct { metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set closeChan chan bool mu sync.RWMutex + templates map[string]*template.Template // Loaded named templates } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -222,8 +223,16 @@ func New(conf *Config) (*Server, error) { messagesHistory: []int64{messages}, visitors: make(map[string]*visitor), stripe: stripe, + templates: make(map[string]*template.Template), } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) + if conf.TemplateDirectory != "" { + tmpls, err := loadTemplatesFromDir(conf.TemplateDirectory) + if err != nil { + return nil, fmt.Errorf("failed to load templates from %s: %w", conf.TemplateDirectory, err) + } + s.templates = tmpls + } return s, nil } @@ -1113,10 +1122,10 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) - if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil { + if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil { return err } - if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil { + if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil { return err } if len(m.Message) > s.config.MessageSizeLimit { @@ -1125,10 +1134,26 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return nil } -func replaceTemplate(tpl string, source string) (string, error) { +func (s *Server) replaceTemplate(tpl string, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } + if strings.HasPrefix(tpl, "@") { + name := strings.TrimPrefix(tpl, "@") + t, ok := s.templates[name] + if !ok { + return "", fmt.Errorf("template '@%s' not found", name) + } + var data any + if err := json.Unmarshal([]byte(source), &data); err != nil { + return "", errHTTPBadRequestTemplateMessageNotJSON + } + var buf bytes.Buffer + if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { + return "", errHTTPBadRequestTemplateExecuteFailed + } + return buf.String(), nil + } var data any if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON @@ -2061,3 +2086,32 @@ func (s *Server) updateAndWriteStats(messagesCount int64) { } }() } + +func loadTemplatesFromDir(dir string) (map[string]*template.Template, error) { + templates := make(map[string]*template.Template) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".tmpl") { + continue + } + path := filepath.Join(dir, name) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read template %s: %w", name, err) + } + tmpl, err := template.New(name).Funcs(sprig.FuncMap()).Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("failed to parse template %s: %w", name, err) + } + base := strings.TrimSuffix(name, ".tmpl") + templates[base] = tmpl + } + return templates, nil +} From b1e935da45365c5e7e731d544a1ad4c7ea3643cd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 16 Jul 2025 13:49:15 +0200 Subject: [PATCH 42/87] TEmplate dir --- cmd/serve.go | 6 +- server/config.go | 4 +- server/errors.go | 3 + server/server.go | 143 ++++++++++++++++++++--------------- server/templates/github.yml | 23 ++++++ server/templates/grafana.yml | 9 +++ server/types.go | 19 ++++- 7 files changed, 142 insertions(+), 65 deletions(-) create mode 100644 server/templates/github.yml create mode 100644 server/templates/grafana.yml diff --git a/cmd/serve.go b/cmd/serve.go index d762a7c6..0cbade0f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -56,6 +56,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Usage: "directory to load named message templates from"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), @@ -107,7 +108,6 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-duration", Aliases: []string{"web_push_expiry_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryDuration), Usage: "automatically expire unused subscriptions after this time"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-expiry-warning-duration", Aliases: []string{"web_push_expiry_warning_duration"}, EnvVars: []string{"NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION"}, Value: util.FormatDuration(server.DefaultWebPushExpiryWarningDuration), Usage: "send web push warning notification after this time before expiring unused subscriptions"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "template-directory", Aliases: []string{"template_directory"}, EnvVars: []string{"NTFY_TEMPLATE_DIRECTORY"}, Usage: "directory to load named templates from"}), ) var cmdServe = &cli.Command{ @@ -162,6 +162,7 @@ func execServe(c *cli.Context) error { attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentExpiryDurationStr := c.String("attachment-expiry-duration") + templateDir := c.String("template-dir") keepaliveIntervalStr := c.String("keepalive-interval") managerIntervalStr := c.String("manager-interval") disallowedTopics := c.StringSlice("disallowed-topics") @@ -206,7 +207,6 @@ func execServe(c *cli.Context) error { metricsListenHTTP := c.String("metrics-listen-http") enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != "" profileListenHTTP := c.String("profile-listen-http") - templateDirectory := c.String("template-directory") // Convert durations cacheDuration, err := util.ParseDuration(cacheDurationStr) @@ -412,6 +412,7 @@ func execServe(c *cli.Context) error { conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration + conf.TemplateDir = templateDir conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.DisallowedTopics = disallowedTopics @@ -463,7 +464,6 @@ func execServe(c *cli.Context) error { conf.WebPushStartupQueries = webPushStartupQueries conf.WebPushExpiryDuration = webPushExpiryDuration conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration - conf.TemplateDirectory = templateDirectory conf.Version = c.App.Version // Set up hot-reloading of config diff --git a/server/config.go b/server/config.go index 46848fe5..c5560010 100644 --- a/server/config.go +++ b/server/config.go @@ -99,6 +99,7 @@ type Config struct { AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration + TemplateDir string // Directory to load named templates from KeepaliveInterval time.Duration ManagerInterval time.Duration DisallowedTopics []string @@ -167,7 +168,6 @@ type Config struct { WebPushExpiryDuration time.Duration WebPushExpiryWarningDuration time.Duration Version string // injected by App - TemplateDirectory string // Directory to load named templates from } // NewConfig instantiates a default new server config @@ -258,6 +258,6 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, - TemplateDirectory: "", + TemplateDir: "", } } diff --git a/server/errors.go b/server/errors.go index c6076f3f..fa504410 100644 --- a/server/errors.go +++ b/server/errors.go @@ -123,6 +123,9 @@ var ( errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} + errHTTPBadRequestTemplateDirectoryNotConfigured = &errHTTP{40046, http.StatusBadRequest, "invalid request: template directory not configured", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} + errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 51c56f3e..c6991ba8 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "gopkg.in/yaml.v2" "io" "net" "net/http" @@ -56,13 +57,12 @@ type Server struct { userManager *user.Manager // Might be nil! messageCache *messageCache // Database that stores the messages webPush *webPushStore // Database that stores web push subscriptions - fileCache *fileCache // File system based cache that stores attachments + fileCache *fileCache // Name system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set closeChan chan bool mu sync.RWMutex - templates map[string]*template.Template // Loaded named templates } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -122,6 +122,15 @@ var ( //go:embed docs docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} + + //go:embed templates + templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...) + templatesDir = "templates" + + // templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they + // are not useful, and seem potentially troublesome. + templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`) + templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) ) const ( @@ -131,17 +140,12 @@ const ( newMessageBody = "New message" // Used in poll requests as generic message defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages - jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher) + jsonBodyBytesLimit = 131072 // Max number of bytes for a request bodys (unless MessageLimit is higher) unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory - templateMaxExecutionTime = 100 * time.Millisecond -) - -var ( - // templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they - // are not useful, and seem potentially troublesome. - templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`) + templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks + templateFileExtension = ".yml" // Template files must end with this extension ) // WebSocket constants @@ -223,16 +227,8 @@ func New(conf *Config) (*Server, error) { messagesHistory: []int64{messages}, visitors: make(map[string]*visitor), stripe: stripe, - templates: make(map[string]*template.Template), } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) - if conf.TemplateDirectory != "" { - tmpls, err := loadTemplatesFromDir(conf.TemplateDirectory) - if err != nil { - return nil, fmt.Errorf("failed to load templates from %s: %w", conf.TemplateDirectory, err) - } - s.templates = tmpls - } return s, nil } @@ -946,7 +942,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = readParam(r, "x-title", "title", "t") @@ -962,7 +958,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -980,19 +976,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { - return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled + return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -1001,27 +997,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache + return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } if call != "" { - return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() { - return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() { - return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -1029,14 +1025,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) + return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error()) } } contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md") if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } - template = readBoolParam(r, false, "x-template", "template", "tpl") + template = templateMode(readParam(r, "x-template", "template", "tpl")) unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! contentEncoding := readParam(r, "content-encoding") if unifiedpush || contentEncoding == "aes128gcm" { @@ -1068,7 +1064,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 7. curl -T file.txt ntfy.sh/mytopic // In all other cases, mostly if file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error { if m.Event == pollRequestEvent { // Case 1 return s.handleBodyDiscard(body) } else if unifiedpush { @@ -1077,8 +1073,8 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body return s.handleBodyAsTextMessage(m, body) // Case 3 } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 - } else if template { - return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5 + } else if template.Enabled() { + return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { return s.handleBodyAsTextMessage(m, body) // Case 6 } @@ -1114,7 +1110,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser return nil } -func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error { +func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error { body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) if err != nil { return err @@ -1122,15 +1118,60 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) + if templateName := template.Name(); templateName != "" { + if err := s.replaceTemplateFromFile(m, templateName, peekedBody); err != nil { + return err + } + } else { + if err := s.replaceTemplateFromParams(m, peekedBody); err != nil { + return err + } + } + if len(m.Message) > s.config.MessageSizeLimit { + return errHTTPBadRequestTemplateMessageTooLarge + } + return nil +} + +func (s *Server) replaceTemplateFromFile(m *message, templateName, peekedBody string) error { + if !templateNameRegex.MatchString(templateName) { + return errHTTPBadRequestTemplateFileNotFound + } + templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first + if s.config.TemplateDir != "" { + if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 { + templateContent = b + } + } + if len(templateContent) == 0 { + return errHTTPBadRequestTemplateFileNotFound + } + var tpl templateFile + if err := yaml.Unmarshal(templateContent, &tpl); err != nil { + return errHTTPBadRequestTemplateFileInvalid + } + var err error + if tpl.Message != nil { + if m.Message, err = s.replaceTemplate(*tpl.Message, peekedBody); err != nil { + return err + } + } + if tpl.Title != nil { + if m.Title, err = s.replaceTemplate(*tpl.Title, peekedBody); err != nil { + return err + } + } + return nil +} + +func (s *Server) replaceTemplateFromParams(m *message, peekedBody string) error { + var err error if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil { return err } if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil { return err } - if len(m.Message) > s.config.MessageSizeLimit { - return errHTTPBadRequestTemplateMessageTooLarge - } return nil } @@ -1138,35 +1179,19 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } - if strings.HasPrefix(tpl, "@") { - name := strings.TrimPrefix(tpl, "@") - t, ok := s.templates[name] - if !ok { - return "", fmt.Errorf("template '@%s' not found", name) - } - var data any - if err := json.Unmarshal([]byte(source), &data); err != nil { - return "", errHTTPBadRequestTemplateMessageNotJSON - } - var buf bytes.Buffer - if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { - return "", errHTTPBadRequestTemplateExecuteFailed - } - return buf.String(), nil - } var data any if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl) if err != nil { - return "", errHTTPBadRequestTemplateInvalid + return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } var buf bytes.Buffer if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { - return "", errHTTPBadRequestTemplateExecuteFailed + return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } - return buf.String(), nil + return strings.TrimSpace(buf.String()), nil } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { diff --git a/server/templates/github.yml b/server/templates/github.yml new file mode 100644 index 00000000..54a17e9b --- /dev/null +++ b/server/templates/github.yml @@ -0,0 +1,23 @@ +message: | + {{- if .pull_request }} + 🔀 PR {{ .action }}: #{{ .pull_request.number }} — {{ .pull_request.title }} + 📦 {{ .repository.full_name }} + 👤 {{ .pull_request.user.login }} + 🌿 {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} + 🔗 {{ .pull_request.html_url }} + 📝 {{ .pull_request.body | default "(no description)" }} + {{- else if and .starred_at (eq .action "created")}} + ⭐ {{ .sender.login }} starred {{ .repository.full_name }} + 📦 {{ .repository.description | default "(no description)" }} + 🔗 {{ .repository.html_url }} + 📅 {{ .starred_at }} + {{- else if and .comment (eq .action "created") }} + 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} + 📦 {{ .repository.full_name }} + 👤 {{ .comment.user.login }} + 🔗 {{ .comment.html_url }} + 📝 {{ .comment.body | default "(no comment body)" }} + {{- else }} + {{ fail "Unsupported GitHub event type or action." }} + {{- end }} + diff --git a/server/templates/grafana.yml b/server/templates/grafana.yml new file mode 100644 index 00000000..42a16deb --- /dev/null +++ b/server/templates/grafana.yml @@ -0,0 +1,9 @@ +message: | + {{if .alerts}} + {{.alerts | len}} alert(s) triggered + {{else}} + No alerts triggered. + {{end}} +title: | + ⚠️ Grafana alert: {{.title}} + diff --git a/server/types.go b/server/types.go index 30f5c468..ea6b8615 100644 --- a/server/types.go +++ b/server/types.go @@ -7,7 +7,6 @@ import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/user" - "heckel.io/ntfy/v2/util" ) @@ -246,6 +245,24 @@ func (q *queryFilter) Pass(msg *message) bool { return true } +type templateMode string + +func (t templateMode) Enabled() bool { + return t != "" +} + +func (t templateMode) Name() string { + if isBoolValue(string(t)) { + return "" + } + return string(t) +} + +type templateFile struct { + Title *string `yaml:"title"` + Message *string `yaml:"message"` +} + type apiHealthResponse struct { Healthy bool `json:"healthy"` } From 610792b9024ad7bce364bd797bd78485b56d2923 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 16 Jul 2025 20:33:52 +0200 Subject: [PATCH 43/87] WIP --- docs/publish.md | 68 +- docs/template-functions.md | 1455 +++++++++++++++++ server/server_test.go | 18 + server/templates/github.yml | 51 +- .../webhook_github_comment_created.json | 261 +++ server/testdata/webhook_github_pr_opened.json | 541 ++++++ .../testdata/webhook_github_star_created.json | 141 ++ .../webhook_github_watch_created.json | 139 ++ 8 files changed, 2640 insertions(+), 34 deletions(-) create mode 100644 docs/template-functions.md create mode 100644 server/testdata/webhook_github_comment_created.json create mode 100644 server/testdata/webhook_github_pr_opened.json create mode 100644 server/testdata/webhook_github_star_created.json create mode 100644 server/testdata/webhook_github_watch_created.json diff --git a/docs/publish.md b/docs/publish.md index 91f75e3d..24aa443a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -950,22 +950,22 @@ Instead of using a separate bridge program to parse the webhook body into the fo message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body is valid JSON). -You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`) to `yes` or `1`, or (more appropriately -for webhooks) by setting the `?template=yes` query parameter. Then, include templates in your `message` and/or `title`, using the following stanzas (see [Go docs](https://pkg.go.dev/text/template) for detailed syntax): +You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`): -* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` -* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) -* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) +* **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) + will enable inline templating, which means that the `message` and/or `title` **will be parsed as a Go template**. + See [Inline templating](#inline-templating) and [Template syntax](#template-syntax) for details on how to use Go + templates in your messages and titles. +* **Pre-defined template files**: You can also set `X-Template` header or query parameter to a template name (e.g. `?template=github`). + ntfy will then read the template from either the built-in pre-defined template files, or from the template files defined in + the `template-dir`. See [Template files](#pre-defined-templates) for more details. -A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test -your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). +### Inline templating -ntfy supports a subset of the Sprig template functions that are included in the **[Go Template Playground](https://repeatit.io)**. Please see -[Template Functions](sprig.md) for a list of supported template functions. - -!!! info - Please note that the Go templating language is quite terrible. My apologies for using it for this feature. It is the best option for Go-based - programs like ntfy. Stay calm and don't harm yourself or others in despair. **You can do it. I believe in you!** +When `X-Template: yes` or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your +webhook payload. This is most useful if no [pre-defined template](#pre-defined-templates) exists, for templated one-off messages, +of if you do not control the ntfy server (e.g., if you're using ntfy.sh). Please consider using [template files](#pre-defined-templates) +if you control the ntfy server, as templates are much easier to maintain. Here's an **example for a Grafana alert**: @@ -1078,6 +1078,48 @@ This example uses the `message`/`m` and `title`/`t` query parameters, but obviou `Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message `Error message: Disk has run out of space`. +### Pre-defined templates + +XXXXXXXXXXXXxx + +### Template syntax +ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, +yet also one of the worst templating languages out there. + +You can use the following features in your templates: + +* Variables, e.g. `{{.alert.title}}` or `An error occurred: {{.error.desc}}` +* Conditionals (if/else, e.g. `{{if eq .action "opened"}}..{{else}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6Ilt7ey5wdWxsX3JlcXVlc3QuaGVhZC5yZXBvLmZ1bGxfbmFtZX19XSBQdWxsIHJlcXVlc3Qge3tpZiBlcSAuYWN0aW9uIFwib3BlbmVkXCJ9fU9QRU5FRHt7ZWxzZX19Q0xPU0VEe3tlbmR9fToge3sucHVsbF9yZXF1ZXN0LnRpdGxlfX0iLCJpbnB1dCI6IntcbiAgXCJhY3Rpb25cIjogXCJvcGVuZWRcIixcbiAgXCJudW1iZXJcIjogMSxcbiAgXCJwdWxsX3JlcXVlc3RcIjoge1xuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy8xXCIsXG4gICAgXCJpZFwiOiAxNzgzNDIwOTcyLFxuICAgIFwibm9kZV9pZFwiOiBcIlBSX2t3RE9IQWJkbzg1cVROZ3NcIixcbiAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiLFxuICAgIFwiZGlmZl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUvcHVsbC8xLmRpZmZcIixcbiAgICBcInBhdGNoX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsLzEucGF0Y2hcIixcbiAgICBcImlzc3VlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIixcbiAgICBcIm51bWJlclwiOiAxLFxuICAgIFwic3RhdGVcIjogXCJvcGVuXCIsXG4gICAgXCJsb2NrZWRcIjogZmFsc2UsXG4gICAgXCJ0aXRsZVwiOiBcIkEgc2FtcGxlIFBSIGZyb20gUGhpbFwiLFxuICAgIFwidXNlclwiOiB7XG4gICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgfSxcbiAgICBcImJvZHlcIjogbnVsbCxcbiAgICBcImNyZWF0ZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjA5WlwiLFxuICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjQtMDMtMjFUMDI6NTI6MDlaXCIsXG4gICAgXCJjbG9zZWRfYXRcIjogbnVsbCxcbiAgICBcIm1lcmdlZF9hdFwiOiBudWxsLFxuICAgIFwibWVyZ2VfY29tbWl0X3NoYVwiOiBudWxsLFxuICAgIFwiYXNzaWduZWVcIjogbnVsbCxcbiAgICBcImFzc2lnbmVlc1wiOiBbXSxcbiAgICBcInJlcXVlc3RlZF9yZXZpZXdlcnNcIjogW10sXG4gICAgXCJyZXF1ZXN0ZWRfdGVhbXNcIjogW10sXG4gICAgXCJsYWJlbHNcIjogW10sXG4gICAgXCJtaWxlc3RvbmVcIjogbnVsbCxcbiAgICBcImRyYWZ0XCI6IGZhbHNlLFxuICAgIFwiY29tbWl0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzLzEvY29tbWl0c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiLFxuICAgIFwicmV2aWV3X2NvbW1lbnRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxscy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvMS9jb21tZW50c1wiLFxuICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCIsXG4gICAgXCJoZWFkXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOmFhXCIsXG4gICAgICBcInJlZlwiOiBcImFhXCIsXG4gICAgICBcInNoYVwiOiBcIjU3MDM4NDJjYzU3MTVlZDFlMzU4ZDIzZWJiNjkzZGIwOTc0N2FlOWJcIixcbiAgICAgIFwidXNlclwiOiB7XG4gICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gICAgICB9LFxuICAgICAgXCJyZXBvXCI6IHtcbiAgICAgICAgXCJpZFwiOiA0NzAyMTIwMDMsXG4gICAgICAgIFwibm9kZV9pZFwiOiBcIlJfa2dET0hBYmRvd1wiLFxuICAgICAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICAgICAgXCJmdWxsX25hbWVcIjogXCJiaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcInByaXZhdGVcIjogZmFsc2UsXG4gICAgICAgIFwib3duZXJcIjoge1xuICAgICAgICAgIFwibG9naW5cIjogXCJiaW53aWVkZXJoaWVyXCIsXG4gICAgICAgICAgXCJpZFwiOiA2NjQ1OTcsXG4gICAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgICBcImF2YXRhcl91cmxcIjogXCJodHRwczovL2F2YXRhcnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tL3UvNjY0NTk3P3Y9NFwiLFxuICAgICAgICAgIFwiZ3JhdmF0YXJfaWRcIjogXCJcIixcbiAgICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaHRtbF91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiZm9sbG93ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dlcnNcIixcbiAgICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICAgIFwiZ2lzdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2dpc3Rzey9naXN0X2lkfVwiLFxuICAgICAgICAgIFwic3RhcnJlZF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3RhcnJlZHsvb3duZXJ9ey9yZXBvfVwiLFxuICAgICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICAgIFwib3JnYW5pemF0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvb3Jnc1wiLFxuICAgICAgICAgIFwicmVwb3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlcG9zXCIsXG4gICAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgICBcInJlY2VpdmVkX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVjZWl2ZWRfZXZlbnRzXCIsXG4gICAgICAgICAgXCJ0eXBlXCI6IFwiVXNlclwiLFxuICAgICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgICB9LFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgICAgIFwiZm9ya1wiOiBmYWxzZSxcbiAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgICAgICBcImtleXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9rZXlzey9rZXlfaWR9XCIsXG4gICAgICAgIFwiY29sbGFib3JhdG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbGxhYm9yYXRvcnN7L2NvbGxhYm9yYXRvcn1cIixcbiAgICAgICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgICAgIFwiaG9va3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ob29rc1wiLFxuICAgICAgICBcImlzc3VlX2V2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9ldmVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICAgICAgXCJhc3NpZ25lZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9hc3NpZ25lZXN7L3VzZXJ9XCIsXG4gICAgICAgIFwiYnJhbmNoZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9icmFuY2hlc3svYnJhbmNofVwiLFxuICAgICAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgICAgIFwiYmxvYnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvYmxvYnN7L3NoYX1cIixcbiAgICAgICAgXCJnaXRfdGFnc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90YWdzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgICAgICBcInRyZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3RyZWVzey9zaGF9XCIsXG4gICAgICAgIFwic3RhdHVzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy97c2hhfVwiLFxuICAgICAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgICAgICBcInN0YXJnYXplcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGFyZ2F6ZXJzXCIsXG4gICAgICAgIFwiY29udHJpYnV0b3JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udHJpYnV0b3JzXCIsXG4gICAgICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgICAgICBcInN1YnNjcmlwdGlvbl91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N1YnNjcmlwdGlvblwiLFxuICAgICAgICBcImNvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21taXRzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImNvbW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWVudHN7L251bWJlcn1cIixcbiAgICAgICAgXCJpc3N1ZV9jb21tZW50X3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgICAgIFwiY29tcGFyZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbXBhcmUve2Jhc2V9Li4ue2hlYWR9XCIsXG4gICAgICAgIFwibWVyZ2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWVyZ2VzXCIsXG4gICAgICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICAgICAgXCJkb3dubG9hZHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9kb3dubG9hZHNcIixcbiAgICAgICAgXCJpc3N1ZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibWlsZXN0b25lc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21pbGVzdG9uZXN7L251bWJlcn1cIixcbiAgICAgICAgXCJub3RpZmljYXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbm90aWZpY2F0aW9uc3s/c2luY2UsYWxsLHBhcnRpY2lwYXRpbmd9XCIsXG4gICAgICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgICAgICBcInJlbGVhc2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcmVsZWFzZXN7L2lkfVwiLFxuICAgICAgICBcImRlcGxveW1lbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZGVwbG95bWVudHNcIixcbiAgICAgICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJ1cGRhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICAgICAgXCJwdXNoZWRfYXRcIjogXCIyMDI0LTAzLTIxVDAyOjUyOjEwWlwiLFxuICAgICAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInNzaF91cmxcIjogXCJnaXRAZ2l0aHViLmNvbTpiaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJjbG9uZV91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImhvbWVwYWdlXCI6IG51bGwsXG4gICAgICAgIFwic2l6ZVwiOiAxLFxuICAgICAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJ3YXRjaGVyc19jb3VudFwiOiAwLFxuICAgICAgICBcImxhbmd1YWdlXCI6IG51bGwsXG4gICAgICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgICAgICBcImhhc19wcm9qZWN0c1wiOiB0cnVlLFxuICAgICAgICBcImhhc19kb3dubG9hZHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgICAgICBcImhhc19wYWdlc1wiOiBmYWxzZSxcbiAgICAgICAgXCJoYXNfZGlzY3Vzc2lvbnNcIjogZmFsc2UsXG4gICAgICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICAgICAgXCJtaXJyb3JfdXJsXCI6IG51bGwsXG4gICAgICAgIFwiYXJjaGl2ZWRcIjogZmFsc2UsXG4gICAgICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNfY291bnRcIjogMSxcbiAgICAgICAgXCJsaWNlbnNlXCI6IG51bGwsXG4gICAgICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgICAgICBcImlzX3RlbXBsYXRlXCI6IGZhbHNlLFxuICAgICAgICBcIndlYl9jb21taXRfc2lnbm9mZl9yZXF1aXJlZFwiOiBmYWxzZSxcbiAgICAgICAgXCJ0b3BpY3NcIjogW10sXG4gICAgICAgIFwidmlzaWJpbGl0eVwiOiBcInB1YmxpY1wiLFxuICAgICAgICBcImZvcmtzXCI6IDAsXG4gICAgICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICAgICAgXCJ3YXRjaGVyc1wiOiAwLFxuICAgICAgICBcImRlZmF1bHRfYnJhbmNoXCI6IFwibWFpblwiLFxuICAgICAgICBcImFsbG93X3NxdWFzaF9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X21lcmdlX2NvbW1pdFwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X3JlYmFzZV9tZXJnZVwiOiB0cnVlLFxuICAgICAgICBcImFsbG93X2F1dG9fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiZGVsZXRlX2JyYW5jaF9vbl9tZXJnZVwiOiBmYWxzZSxcbiAgICAgICAgXCJhbGxvd191cGRhdGVfYnJhbmNoXCI6IGZhbHNlLFxuICAgICAgICBcInVzZV9zcXVhc2hfcHJfdGl0bGVfYXNfZGVmYXVsdFwiOiBmYWxzZSxcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJDT01NSVRfTUVTU0FHRVNcIixcbiAgICAgICAgXCJzcXVhc2hfbWVyZ2VfY29tbWl0X3RpdGxlXCI6IFwiQ09NTUlUX09SX1BSX1RJVExFXCIsXG4gICAgICAgIFwibWVyZ2VfY29tbWl0X21lc3NhZ2VcIjogXCJQUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF90aXRsZVwiOiBcIk1FUkdFX01FU1NBR0VcIlxuICAgICAgfVxuICAgIH0sXG4gICAgXCJiYXNlXCI6IHtcbiAgICAgIFwibGFiZWxcIjogXCJiaW53aWVkZXJoaWVyOm1haW5cIixcbiAgICAgIFwicmVmXCI6IFwibWFpblwiLFxuICAgICAgXCJzaGFcIjogXCI3MmQ5MzFhMjBiYjgzZDEyM2FiNDVhY2NhZjc2MTE1MGM4YjAxMjExXCIsXG4gICAgICBcInVzZXJcIjoge1xuICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImlkXCI6IDY2NDU5NyxcbiAgICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgICAgfSxcbiAgICAgIFwicmVwb1wiOiB7XG4gICAgICAgIFwiaWRcIjogNDcwMjEyMDAzLFxuICAgICAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICAgICAgXCJuYW1lXCI6IFwiZGFiYmxlXCIsXG4gICAgICAgIFwiZnVsbF9uYW1lXCI6IFwiYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgICAgICBcIm93bmVyXCI6IHtcbiAgICAgICAgICBcImxvZ2luXCI6IFwiYmlud2llZGVyaGllclwiLFxuICAgICAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgICAgIFwibm9kZV9pZFwiOiBcIk1EUTZWWE5sY2pZMk5EVTVOdz09XCIsXG4gICAgICAgICAgXCJhdmF0YXJfdXJsXCI6IFwiaHR0cHM6Ly9hdmF0YXJzLmdpdGh1YnVzZXJjb250ZW50LmNvbS91LzY2NDU5Nz92PTRcIixcbiAgICAgICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICAgICAgXCJ1cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImh0bWxfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXJcIixcbiAgICAgICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICAgICAgXCJmb2xsb3dpbmdfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2luZ3svb3RoZXJfdXNlcn1cIixcbiAgICAgICAgICBcImdpc3RzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9naXN0c3svZ2lzdF9pZH1cIixcbiAgICAgICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgICAgICBcInN1YnNjcmlwdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N1YnNjcmlwdGlvbnNcIixcbiAgICAgICAgICBcIm9yZ2FuaXphdGlvbnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL29yZ3NcIixcbiAgICAgICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9ldmVudHN7L3ByaXZhY3l9XCIsXG4gICAgICAgICAgXCJyZWNlaXZlZF9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3JlY2VpdmVkX2V2ZW50c1wiLFxuICAgICAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgICAgICBcInNpdGVfYWRtaW5cIjogZmFsc2VcbiAgICAgICAgfSxcbiAgICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImRlc2NyaXB0aW9uXCI6IFwiQSByZXBvIGZvciBkYWJibGluZ1wiLFxuICAgICAgICBcImZvcmtcIjogZmFsc2UsXG4gICAgICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgICAgICBcImZvcmtzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZm9ya3NcIixcbiAgICAgICAgXCJrZXlzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUva2V5c3sva2V5X2lkfVwiLFxuICAgICAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgICAgIFwidGVhbXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90ZWFtc1wiLFxuICAgICAgICBcImhvb2tzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaG9va3NcIixcbiAgICAgICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZXZlbnRzXCIsXG4gICAgICAgIFwiYXNzaWduZWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYXNzaWduZWVzey91c2VyfVwiLFxuICAgICAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICAgICAgXCJ0YWdzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvdGFnc1wiLFxuICAgICAgICBcImJsb2JzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2Jsb2Jzey9zaGF9XCIsXG4gICAgICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgICAgICBcImdpdF9yZWZzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L3JlZnN7L3NoYX1cIixcbiAgICAgICAgXCJ0cmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC90cmVlc3svc2hhfVwiLFxuICAgICAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICAgICAgXCJsYW5ndWFnZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9sYW5ndWFnZXNcIixcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhcmdhemVyc1wiLFxuICAgICAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgICAgICBcInN1YnNjcmliZXJzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaWJlcnNcIixcbiAgICAgICAgXCJzdWJzY3JpcHRpb25fdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpcHRpb25cIixcbiAgICAgICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgICAgICBcImdpdF9jb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZ2l0L2NvbW1pdHN7L3NoYX1cIixcbiAgICAgICAgXCJjb21tZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbW1lbnRzey9udW1iZXJ9XCIsXG4gICAgICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgICAgICBcImNvbnRlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29udGVudHMveytwYXRofVwiLFxuICAgICAgICBcImNvbXBhcmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21wYXJlL3tiYXNlfS4uLntoZWFkfVwiLFxuICAgICAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgICAgICBcImFyY2hpdmVfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS97YXJjaGl2ZV9mb3JtYXR9ey9yZWZ9XCIsXG4gICAgICAgIFwiZG93bmxvYWRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvZG93bmxvYWRzXCIsXG4gICAgICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwicHVsbHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9wdWxsc3svbnVtYmVyfVwiLFxuICAgICAgICBcIm1pbGVzdG9uZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9taWxlc3RvbmVzey9udW1iZXJ9XCIsXG4gICAgICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgICAgICBcImxhYmVsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhYmVsc3svbmFtZX1cIixcbiAgICAgICAgXCJyZWxlYXNlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3JlbGVhc2Vzey9pZH1cIixcbiAgICAgICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgICAgIFwiY3JlYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwidXBkYXRlZF9hdFwiOiBcIjIwMjItMDMtMTVUMTU6MDY6MTdaXCIsXG4gICAgICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICAgICAgXCJnaXRfdXJsXCI6IFwiZ2l0Oi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZS5naXRcIixcbiAgICAgICAgXCJzc2hfdXJsXCI6IFwiZ2l0QGdpdGh1Yi5jb206Ymlud2llZGVyaGllci9kYWJibGUuZ2l0XCIsXG4gICAgICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgICAgICBcInN2bl91cmxcIjogXCJodHRwczovL2dpdGh1Yi5jb20vYmlud2llZGVyaGllci9kYWJibGVcIixcbiAgICAgICAgXCJob21lcGFnZVwiOiBudWxsLFxuICAgICAgICBcInNpemVcIjogMSxcbiAgICAgICAgXCJzdGFyZ2F6ZXJzX2NvdW50XCI6IDAsXG4gICAgICAgIFwid2F0Y2hlcnNfY291bnRcIjogMCxcbiAgICAgICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgICAgICBcImhhc19pc3N1ZXNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcHJvamVjdHNcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgICAgIFwiaGFzX3dpa2lcIjogdHJ1ZSxcbiAgICAgICAgXCJoYXNfcGFnZXNcIjogZmFsc2UsXG4gICAgICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgICAgICBcImZvcmtzX2NvdW50XCI6IDAsXG4gICAgICAgIFwibWlycm9yX3VybFwiOiBudWxsLFxuICAgICAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgICAgICBcImRpc2FibGVkXCI6IGZhbHNlLFxuICAgICAgICBcIm9wZW5faXNzdWVzX2NvdW50XCI6IDEsXG4gICAgICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgICAgICBcImFsbG93X2ZvcmtpbmdcIjogdHJ1ZSxcbiAgICAgICAgXCJpc190ZW1wbGF0ZVwiOiBmYWxzZSxcbiAgICAgICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgICAgIFwidG9waWNzXCI6IFtdLFxuICAgICAgICBcInZpc2liaWxpdHlcIjogXCJwdWJsaWNcIixcbiAgICAgICAgXCJmb3Jrc1wiOiAwLFxuICAgICAgICBcIm9wZW5faXNzdWVzXCI6IDEsXG4gICAgICAgIFwid2F0Y2hlcnNcIjogMCxcbiAgICAgICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIixcbiAgICAgICAgXCJhbGxvd19zcXVhc2hfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19tZXJnZV9jb21taXRcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19yZWJhc2VfbWVyZ2VcIjogdHJ1ZSxcbiAgICAgICAgXCJhbGxvd19hdXRvX21lcmdlXCI6IGZhbHNlLFxuICAgICAgICBcImRlbGV0ZV9icmFuY2hfb25fbWVyZ2VcIjogZmFsc2UsXG4gICAgICAgIFwiYWxsb3dfdXBkYXRlX2JyYW5jaFwiOiBmYWxzZSxcbiAgICAgICAgXCJ1c2Vfc3F1YXNoX3ByX3RpdGxlX2FzX2RlZmF1bHRcIjogZmFsc2UsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiQ09NTUlUX01FU1NBR0VTXCIsXG4gICAgICAgIFwic3F1YXNoX21lcmdlX2NvbW1pdF90aXRsZVwiOiBcIkNPTU1JVF9PUl9QUl9USVRMRVwiLFxuICAgICAgICBcIm1lcmdlX2NvbW1pdF9tZXNzYWdlXCI6IFwiUFJfVElUTEVcIixcbiAgICAgICAgXCJtZXJnZV9jb21taXRfdGl0bGVcIjogXCJNRVJHRV9NRVNTQUdFXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiX2xpbmtzXCI6IHtcbiAgICAgIFwic2VsZlwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMVwiXG4gICAgICB9LFxuICAgICAgXCJodG1sXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGwvMVwiXG4gICAgICB9LFxuICAgICAgXCJpc3N1ZVwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzLzFcIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWVudHNcIjoge1xuICAgICAgICBcImhyZWZcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy8xL2NvbW1lbnRzXCJcbiAgICAgIH0sXG4gICAgICBcInJldmlld19jb21tZW50c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21tZW50c1wiXG4gICAgICB9LFxuICAgICAgXCJyZXZpZXdfY29tbWVudFwiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvY29tbWVudHN7L251bWJlcn1cIlxuICAgICAgfSxcbiAgICAgIFwiY29tbWl0c1wiOiB7XG4gICAgICAgIFwiaHJlZlwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvcHVsbHMvMS9jb21taXRzXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXR1c2VzXCI6IHtcbiAgICAgICAgXCJocmVmXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdGF0dXNlcy81NzAzODQyY2M1NzE1ZWQxZTM1OGQyM2ViYjY5M2RiMDk3NDdhZTliXCJcbiAgICAgIH1cbiAgICB9LFxuICAgIFwiYXV0aG9yX2Fzc29jaWF0aW9uXCI6IFwiT1dORVJcIixcbiAgICBcImF1dG9fbWVyZ2VcIjogbnVsbCxcbiAgICBcImFjdGl2ZV9sb2NrX3JlYXNvblwiOiBudWxsLFxuICAgIFwibWVyZ2VkXCI6IGZhbHNlLFxuICAgIFwibWVyZ2VhYmxlXCI6IG51bGwsXG4gICAgXCJyZWJhc2VhYmxlXCI6IG51bGwsXG4gICAgXCJtZXJnZWFibGVfc3RhdGVcIjogXCJ1bmtub3duXCIsXG4gICAgXCJtZXJnZWRfYnlcIjogbnVsbCxcbiAgICBcImNvbW1lbnRzXCI6IDAsXG4gICAgXCJyZXZpZXdfY29tbWVudHNcIjogMCxcbiAgICBcIm1haW50YWluZXJfY2FuX21vZGlmeVwiOiBmYWxzZSxcbiAgICBcImNvbW1pdHNcIjogMSxcbiAgICBcImFkZGl0aW9uc1wiOiAxLFxuICAgIFwiZGVsZXRpb25zXCI6IDEsXG4gICAgXCJjaGFuZ2VkX2ZpbGVzXCI6IDFcbiAgfSxcbiAgXCJyZXBvc2l0b3J5XCI6IHtcbiAgICBcImlkXCI6IDQ3MDIxMjAwMyxcbiAgICBcIm5vZGVfaWRcIjogXCJSX2tnRE9IQWJkb3dcIixcbiAgICBcIm5hbWVcIjogXCJkYWJibGVcIixcbiAgICBcImZ1bGxfbmFtZVwiOiBcImJpbndpZWRlcmhpZXIvZGFiYmxlXCIsXG4gICAgXCJwcml2YXRlXCI6IGZhbHNlLFxuICAgIFwib3duZXJcIjoge1xuICAgICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICAgIFwiaWRcIjogNjY0NTk3LFxuICAgICAgXCJub2RlX2lkXCI6IFwiTURRNlZYTmxjalkyTkRVNU53PT1cIixcbiAgICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgICBcImdyYXZhdGFyX2lkXCI6IFwiXCIsXG4gICAgICBcInVybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllclwiLFxuICAgICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgICBcImZvbGxvd2Vyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93ZXJzXCIsXG4gICAgICBcImZvbGxvd2luZ191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZm9sbG93aW5ney9vdGhlcl91c2VyfVwiLFxuICAgICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgICBcInN0YXJyZWRfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL3N0YXJyZWR7L293bmVyfXsvcmVwb31cIixcbiAgICAgIFwic3Vic2NyaXB0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvc3Vic2NyaXB0aW9uc1wiLFxuICAgICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgICBcInJlcG9zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZXBvc1wiLFxuICAgICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2V2ZW50c3svcHJpdmFjeX1cIixcbiAgICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICAgIFwidHlwZVwiOiBcIlVzZXJcIixcbiAgICAgIFwic2l0ZV9hZG1pblwiOiBmYWxzZVxuICAgIH0sXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZGVzY3JpcHRpb25cIjogXCJBIHJlcG8gZm9yIGRhYmJsaW5nXCIsXG4gICAgXCJmb3JrXCI6IGZhbHNlLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiZm9ya3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9mb3Jrc1wiLFxuICAgIFwia2V5c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2tleXN7L2tleV9pZH1cIixcbiAgICBcImNvbGxhYm9yYXRvcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb2xsYWJvcmF0b3Jzey9jb2xsYWJvcmF0b3J9XCIsXG4gICAgXCJ0ZWFtc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3RlYW1zXCIsXG4gICAgXCJob29rc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2hvb2tzXCIsXG4gICAgXCJpc3N1ZV9ldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9pc3N1ZXMvZXZlbnRzey9udW1iZXJ9XCIsXG4gICAgXCJldmVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9ldmVudHNcIixcbiAgICBcImFzc2lnbmVlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Fzc2lnbmVlc3svdXNlcn1cIixcbiAgICBcImJyYW5jaGVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvYnJhbmNoZXN7L2JyYW5jaH1cIixcbiAgICBcInRhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS90YWdzXCIsXG4gICAgXCJibG9ic191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2dpdC9ibG9ic3svc2hhfVwiLFxuICAgIFwiZ2l0X3RhZ3NfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdGFnc3svc2hhfVwiLFxuICAgIFwiZ2l0X3JlZnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvcmVmc3svc2hhfVwiLFxuICAgIFwidHJlZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvdHJlZXN7L3NoYX1cIixcbiAgICBcInN0YXR1c2VzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3RhdHVzZXMve3NoYX1cIixcbiAgICBcImxhbmd1YWdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2xhbmd1YWdlc1wiLFxuICAgIFwic3RhcmdhemVyc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3N0YXJnYXplcnNcIixcbiAgICBcImNvbnRyaWJ1dG9yc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2NvbnRyaWJ1dG9yc1wiLFxuICAgIFwic3Vic2NyaWJlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9zdWJzY3JpYmVyc1wiLFxuICAgIFwic3Vic2NyaXB0aW9uX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvc3Vic2NyaXB0aW9uXCIsXG4gICAgXCJjb21taXRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiZ2l0X2NvbW1pdHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9naXQvY29tbWl0c3svc2hhfVwiLFxuICAgIFwiY29tbWVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiaXNzdWVfY29tbWVudF91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2lzc3Vlcy9jb21tZW50c3svbnVtYmVyfVwiLFxuICAgIFwiY29udGVudHNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9jb250ZW50cy97K3BhdGh9XCIsXG4gICAgXCJjb21wYXJlX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvY29tcGFyZS97YmFzZX0uLi57aGVhZH1cIixcbiAgICBcIm1lcmdlc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL21lcmdlc1wiLFxuICAgIFwiYXJjaGl2ZV91cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3thcmNoaXZlX2Zvcm1hdH17L3JlZn1cIixcbiAgICBcImRvd25sb2Fkc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2Rvd25sb2Fkc1wiLFxuICAgIFwiaXNzdWVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvaXNzdWVzey9udW1iZXJ9XCIsXG4gICAgXCJwdWxsc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL3B1bGxzey9udW1iZXJ9XCIsXG4gICAgXCJtaWxlc3RvbmVzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbWlsZXN0b25lc3svbnVtYmVyfVwiLFxuICAgIFwibm90aWZpY2F0aW9uc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL25vdGlmaWNhdGlvbnN7P3NpbmNlLGFsbCxwYXJ0aWNpcGF0aW5nfVwiLFxuICAgIFwibGFiZWxzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vcmVwb3MvYmlud2llZGVyaGllci9kYWJibGUvbGFiZWxzey9uYW1lfVwiLFxuICAgIFwicmVsZWFzZXNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9iaW53aWVkZXJoaWVyL2RhYmJsZS9yZWxlYXNlc3svaWR9XCIsXG4gICAgXCJkZXBsb3ltZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2JpbndpZWRlcmhpZXIvZGFiYmxlL2RlcGxveW1lbnRzXCIsXG4gICAgXCJjcmVhdGVkX2F0XCI6IFwiMjAyMi0wMy0xNVQxNTowNjoxN1pcIixcbiAgICBcInVwZGF0ZWRfYXRcIjogXCIyMDIyLTAzLTE1VDE1OjA2OjE3WlwiLFxuICAgIFwicHVzaGVkX2F0XCI6IFwiMjAyNC0wMy0yMVQwMjo1MjoxMFpcIixcbiAgICBcImdpdF91cmxcIjogXCJnaXQ6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3NoX3VybFwiOiBcImdpdEBnaXRodWIuY29tOmJpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwiY2xvbmVfdXJsXCI6IFwiaHR0cHM6Ly9naXRodWIuY29tL2JpbndpZWRlcmhpZXIvZGFiYmxlLmdpdFwiLFxuICAgIFwic3ZuX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyL2RhYmJsZVwiLFxuICAgIFwiaG9tZXBhZ2VcIjogbnVsbCxcbiAgICBcInNpemVcIjogMSxcbiAgICBcInN0YXJnYXplcnNfY291bnRcIjogMCxcbiAgICBcIndhdGNoZXJzX2NvdW50XCI6IDAsXG4gICAgXCJsYW5ndWFnZVwiOiBudWxsLFxuICAgIFwiaGFzX2lzc3Vlc1wiOiB0cnVlLFxuICAgIFwiaGFzX3Byb2plY3RzXCI6IHRydWUsXG4gICAgXCJoYXNfZG93bmxvYWRzXCI6IHRydWUsXG4gICAgXCJoYXNfd2lraVwiOiB0cnVlLFxuICAgIFwiaGFzX3BhZ2VzXCI6IGZhbHNlLFxuICAgIFwiaGFzX2Rpc2N1c3Npb25zXCI6IGZhbHNlLFxuICAgIFwiZm9ya3NfY291bnRcIjogMCxcbiAgICBcIm1pcnJvcl91cmxcIjogbnVsbCxcbiAgICBcImFyY2hpdmVkXCI6IGZhbHNlLFxuICAgIFwiZGlzYWJsZWRcIjogZmFsc2UsXG4gICAgXCJvcGVuX2lzc3Vlc19jb3VudFwiOiAxLFxuICAgIFwibGljZW5zZVwiOiBudWxsLFxuICAgIFwiYWxsb3dfZm9ya2luZ1wiOiB0cnVlLFxuICAgIFwiaXNfdGVtcGxhdGVcIjogZmFsc2UsXG4gICAgXCJ3ZWJfY29tbWl0X3NpZ25vZmZfcmVxdWlyZWRcIjogZmFsc2UsXG4gICAgXCJ0b3BpY3NcIjogW10sXG4gICAgXCJ2aXNpYmlsaXR5XCI6IFwicHVibGljXCIsXG4gICAgXCJmb3Jrc1wiOiAwLFxuICAgIFwib3Blbl9pc3N1ZXNcIjogMSxcbiAgICBcIndhdGNoZXJzXCI6IDAsXG4gICAgXCJkZWZhdWx0X2JyYW5jaFwiOiBcIm1haW5cIlxuICB9LFxuICBcInNlbmRlclwiOiB7XG4gICAgXCJsb2dpblwiOiBcImJpbndpZWRlcmhpZXJcIixcbiAgICBcImlkXCI6IDY2NDU5NyxcbiAgICBcIm5vZGVfaWRcIjogXCJNRFE2VlhObGNqWTJORFU1Tnc9PVwiLFxuICAgIFwiYXZhdGFyX3VybFwiOiBcImh0dHBzOi8vYXZhdGFycy5naXRodWJ1c2VyY29udGVudC5jb20vdS82NjQ1OTc/dj00XCIsXG4gICAgXCJncmF2YXRhcl9pZFwiOiBcIlwiLFxuICAgIFwidXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJodG1sX3VybFwiOiBcImh0dHBzOi8vZ2l0aHViLmNvbS9iaW53aWVkZXJoaWVyXCIsXG4gICAgXCJmb2xsb3dlcnNfdXJsXCI6IFwiaHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS91c2Vycy9iaW53aWVkZXJoaWVyL2ZvbGxvd2Vyc1wiLFxuICAgIFwiZm9sbG93aW5nX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9mb2xsb3dpbmd7L290aGVyX3VzZXJ9XCIsXG4gICAgXCJnaXN0c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZ2lzdHN7L2dpc3RfaWR9XCIsXG4gICAgXCJzdGFycmVkX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdGFycmVkey9vd25lcn17L3JlcG99XCIsXG4gICAgXCJzdWJzY3JpcHRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9zdWJzY3JpcHRpb25zXCIsXG4gICAgXCJvcmdhbml6YXRpb25zX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9vcmdzXCIsXG4gICAgXCJyZXBvc191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvcmVwb3NcIixcbiAgICBcImV2ZW50c191cmxcIjogXCJodHRwczovL2FwaS5naXRodWIuY29tL3VzZXJzL2JpbndpZWRlcmhpZXIvZXZlbnRzey9wcml2YWN5fVwiLFxuICAgIFwicmVjZWl2ZWRfZXZlbnRzX3VybFwiOiBcImh0dHBzOi8vYXBpLmdpdGh1Yi5jb20vdXNlcnMvYmlud2llZGVyaGllci9yZWNlaXZlZF9ldmVudHNcIixcbiAgICBcInR5cGVcIjogXCJVc2VyXCIsXG4gICAgXCJzaXRlX2FkbWluXCI6IGZhbHNlXG4gIH1cbn1cbiIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) +* Loops (e.g. `{{range .errors}}..{{end}}`, see [example](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6IlNldmVyZSBVUkxzOlxue3tyYW5nZSAuZXJyb3JzfX17e2lmIGVxIC5sZXZlbCBcInNldmVyZVwifX0tIHt7LnVybH19XG57e2VuZH19e3tlbmR9fSIsImlucHV0Ijoie1wiZm9vXCI6IFwiYmFyXCIsIFwiZXJyb3JzXCI6IFt7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMS5jb21cIn0se1wibGV2ZWxcIjogXCJ3YXJuaW5nXCIsIFwidXJsXCI6IFwiaHR0cHM6Ly93YXJuaW5nLmNvbVwifSx7XCJsZXZlbFwiOiBcInNldmVyZVwiLCBcInVybFwiOiBcImh0dHBzOi8vc2V2ZXJlMi5jb21cIn1dfSIsImNvbmZpZyI6eyJ0ZW1wbGF0ZSI6InRleHQiLCJmdWxsU2NyZWVuSFRNTCI6ZmFsc2UsImZ1bmN0aW9ucyI6WyJzcHJpZyJdLCJvcHRpb25zIjpbImxpdmUiXSwiaW5wdXRUeXBlIjoieWFtbCJ9fQ==)) + +A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test +your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). + +### Template functions +ntfy supports a subset of the [Sprig](https://github.com/Masterminds/sprig) template functions. This is useful for advanced +message templating and for transforming the data provided through the JSON payload. + +Below are the functions that are available to use inside your message/title templates. + +* [String Functions](./sprig/strings.md): `trim`, `trunc`, `substr`, `plural`, etc. + * [String List Functions](./sprig/string_slice.md): `splitList`, `sortAlpha`, etc. +* [Integer Math Functions](./sprig/math.md): `add`, `max`, `mul`, etc. + * [Integer List Functions](./sprig/integer_slice.md): `until`, `untilStep` +* [Date Functions](./sprig/date.md): `now`, `date`, etc. +* [Defaults Functions](./sprig/defaults.md): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` +* [Encoding Functions](./sprig/encoding.md): `b64enc`, `b64dec`, etc. +* [Lists and List Functions](./sprig/lists.md): `list`, `first`, `uniq`, etc. +* [Dictionaries and Dict Functions](./sprig/dicts.md): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. +* [Type Conversion Functions](./sprig/conversion.md): `atoi`, `int64`, `toString`, etc. +* [Path and Filepath Functions](./sprig/paths.md): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` +* [Flow Control Functions]( ./sprig/flow_control.md): `fail` +* Advanced Functions + * [UUID Functions](./sprig/uuid.md): `uuidv4` + * [Reflection](./sprig/reflection.md): `typeOf`, `kindIs`, `typeIsLike`, etc. + * [Cryptographic and Security Functions](./sprig/crypto.md): `sha256sum`, etc. + * [URL](./sprig/url.md): `urlParse`, `urlJoin` + + ## Publish as JSON _Supported on:_ :material-android: :material-apple: :material-firefox: diff --git a/docs/template-functions.md b/docs/template-functions.md new file mode 100644 index 00000000..75c0e7c4 --- /dev/null +++ b/docs/template-functions.md @@ -0,0 +1,1455 @@ +# String Functions + +Sprig has a number of string manipulation functions. + +## trim + +The `trim` function removes space from either side of a string: + +``` +trim " hello " +``` + +The above produces `hello` + +## trimAll + +Remove given characters from the front or back of a string: + +``` +trimAll "$" "$5.00" +``` + +The above returns `5.00` (as a string). + +## trimSuffix + +Trim just the suffix from a string: + +``` +trimSuffix "-" "hello-" +``` + +The above returns `hello` + +## trimPrefix + +Trim just the prefix from a string: + +``` +trimPrefix "-" "-hello" +``` + +The above returns `hello` + +## upper + +Convert the entire string to uppercase: + +``` +upper "hello" +``` + +The above returns `HELLO` + +## lower + +Convert the entire string to lowercase: + +``` +lower "HELLO" +``` + +The above returns `hello` + +## title + +Convert to title case: + +``` +title "hello world" +``` + +The above returns `Hello World` + +## repeat + +Repeat a string multiple times: + +``` +repeat 3 "hello" +``` + +The above returns `hellohellohello` + +## substr + +Get a substring from a string. It takes three parameters: + +- start (int) +- end (int) +- string (string) + +``` +substr 0 5 "hello world" +``` + +The above returns `hello` + +## trunc + +Truncate a string (and add no suffix) + +``` +trunc 5 "hello world" +``` + +The above produces `hello`. + +``` +trunc -5 "hello world" +``` + +The above produces `world`. + +## contains + +Test to see if one string is contained inside of another: + +``` +contains "cat" "catch" +``` + +The above returns `true` because `catch` contains `cat`. + +## hasPrefix and hasSuffix + +The `hasPrefix` and `hasSuffix` functions test whether a string has a given +prefix or suffix: + +``` +hasPrefix "cat" "catch" +``` + +The above returns `true` because `catch` has the prefix `cat`. + +## quote and squote + +These functions wrap a string in double quotes (`quote`) or single quotes +(`squote`). + +## cat + +The `cat` function concatenates multiple strings together into one, separating +them with spaces: + +``` +cat "hello" "beautiful" "world" +``` + +The above produces `hello beautiful world` + +## indent + +The `indent` function indents every line in a given string to the specified +indent width. This is useful when aligning multi-line strings: + +``` +indent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters. + +## nindent + +The `nindent` function is the same as the indent function, but prepends a new +line to the beginning of the string. + +``` +nindent 4 $lots_of_text +``` + +The above will indent every line of text by 4 space characters and add a new +line to the beginning. + +## replace + +Perform simple string replacement. + +It takes three arguments: + +- string to replace +- string to replace with +- source string + +``` +"I Am Henry VIII" | replace " " "-" +``` + +The above will produce `I-Am-Henry-VIII` + +## plural + +Pluralize a string. + +``` +len $fish | plural "one anchovy" "many anchovies" +``` + +In the above, if the length of the string is 1, the first argument will be +printed (`one anchovy`). Otherwise, the second argument will be printed +(`many anchovies`). + +The arguments are: + +- singular string +- plural string +- length integer + +NOTE: Sprig does not currently support languages with more complex pluralization +rules. And `0` is considered a plural because the English language treats it +as such (`zero anchovies`). The Sprig developers are working on a solution for +better internationalization. + +## regexMatch, mustRegexMatch + +Returns true if the input string contains any match of the regular expression. + +``` +regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" +``` + +The above produces `true` + +`regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the +template engine if there is a problem. + +## regexFindAll, mustRegexFindAll + +Returns a slice of all matches of the regular expression in the input string. +The last parameter n determines the number of substrings to return, where -1 means return all matches + +``` +regexFindAll "[2,4,6,8]" "123456789" -1 +``` + +The above produces `[2 4 6 8]` + +`regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the +template engine if there is a problem. + +## regexFind, mustRegexFind + +Return the first (left most) match of the regular expression in the input string + +``` +regexFind "[a-zA-Z][1-9]" "abcd1234" +``` + +The above produces `d1` + +`regexFind` panics if there is a problem and `mustRegexFind` returns an error to the +template engine if there is a problem. + +## regexReplaceAll, mustRegexReplaceAll + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. +Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch + +``` +regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" +``` + +The above produces `-W-xxW-` + +`regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the +template engine if there is a problem. + +## regexReplaceAllLiteral, mustRegexReplaceAllLiteral + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement +The replacement string is substituted directly, without using Expand + +``` +regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}" +``` + +The above produces `-${1}-${1}-` + +`regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the +template engine if there is a problem. + +## regexSplit, mustRegexSplit + +Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches + +``` +regexSplit "z+" "pizza" -1 +``` + +The above produces `[pi a]` + +`regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the +template engine if there is a problem. + +## regexQuoteMeta + +Returns a string that escapes all regular expression metacharacters inside the argument text; +the returned string is a regular expression matching the literal text. + +``` +regexQuoteMeta "1.2.3" +``` + +The above produces `1\.2\.3` + +## See Also... + +The [Conversion Functions](conversion.md) contain functions for converting strings. The [String List Functions](string_slice.md) contains +functions for working with an array of strings. +# String List Functions + +These function operate on or generate slices of strings. In Go, a slice is a +growable array. In Sprig, it's a special case of a `list`. + +## join + +Join a list of strings into a single string, with the given separator. + +``` +list "hello" "world" | join "_" +``` + +The above will produce `hello_world` + +`join` will try to convert non-strings to a string value: + +``` +list 1 2 3 | join "+" +``` + +The above will produce `1+2+3` + +## splitList and split + +Split a string into a list of strings: + +``` +splitList "$" "foo$bar$baz" +``` + +The above will return `[foo bar baz]` + +The older `split` function splits a string into a `dict`. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := split "$" "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar, _2: baz}` + +``` +$a._0 +``` + +The above produces `foo` + +## splitn + +`splitn` function splits a string into a `dict` with `n` keys. It is designed to make +it easy to use template dot notation for accessing members: + +``` +$a := splitn "$" 2 "foo$bar$baz" +``` + +The above produces a map with index keys. `{_0: foo, _1: bar$baz}` + +``` +$a._0 +``` + +The above produces `foo` + +## sortAlpha + +The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) +order. + +It does _not_ sort in place, but returns a sorted copy of the list, in keeping +with the immutability of lists. +# Integer Math Functions + +The following math functions operate on `int64` values. + +## add + +Sum numbers with `add`. Accepts two or more inputs. + +``` +add 1 2 3 +``` + +## add1 + +To increment by 1, use `add1` + +## sub + +To subtract, use `sub` + +## div + +Perform integer division with `div` + +## mod + +Modulo with `mod` + +## mul + +Multiply with `mul`. Accepts two or more inputs. + +``` +mul 1 2 3 +``` + +## max + +Return the largest of a series of integers: + +This will return `3`: + +``` +max 1 2 3 +``` + +## min + +Return the smallest of a series of integers. + +`min 1 2 3` will return `1` + +## floor + +Returns the greatest float value less than or equal to input value + +`floor 123.9999` will return `123.0` + +## ceil + +Returns the greatest float value greater than or equal to input value + +`ceil 123.001` will return `124.0` + +## round + +Returns a float value with the remainder rounded to the given number to digits after the decimal point. + +`round 123.555555 3` will return `123.556` + +## randInt +Returns a random integer value from min (inclusive) to max (exclusive). + +``` +randInt 12 30 +``` + +The above will produce a random number in the range [12,30]. +# Integer List Functions + +## until + +The `until` function builds a range of integers. + +``` +until 5 +``` + +The above generates the list `[0, 1, 2, 3, 4]`. + +This is useful for looping with `range $i, $e := until 5`. + +## untilStep + +Like `until`, `untilStep` generates a list of counting integers. But it allows +you to define a start, stop, and step: + +``` +untilStep 3 6 2 +``` + +The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal +or greater than 6. This is similar to Python's `range` function. + +## seq + +Works like the bash `seq` command. +* 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. +* 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. +* 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. + +``` +seq 5 => 1 2 3 4 5 +seq -3 => 1 0 -1 -2 -3 +seq 0 2 => 0 1 2 +seq 2 -2 => 2 1 0 -1 -2 +seq 0 2 10 => 0 2 4 6 8 10 +seq 0 -2 -5 => 0 -2 -4 +``` +# Date Functions + +## now + +The current date/time. Use this in conjunction with other date functions. + +## ago + +The `ago` function returns duration from time.Now in seconds resolution. + +``` +ago .CreatedAt +``` + +returns in `time.Duration` String() format + +``` +2h34m7s +``` + +## date + +The `date` function formats a date. + +Format the date to YEAR-MONTH-DAY: + +``` +now | date "2006-01-02" +``` + +Date formatting in Go is a [little bit different](https://pauladamsmith.com/blog/2011/05/go_time.html). + +In short, take this as the base date: + +``` +Mon Jan 2 15:04:05 MST 2006 +``` + +Write it in the format you want. Above, `2006-01-02` is the same date, but +in the format we want. + +## dateInZone + +Same as `date`, but with a timezone. + +``` +dateInZone "2006-01-02" (now) "UTC" +``` + +## duration + +Formats a given amount of seconds as a `time.Duration`. + +This returns 1m35s + +``` +duration "95" +``` + +## durationRound + +Rounds a given duration to the most significant unit. Strings and `time.Duration` +gets parsed as a duration, while a `time.Time` is calculated as the duration since. + +This return 2h + +``` +durationRound "2h10m5s" +``` + +This returns 3mo + +``` +durationRound "2400h10m5s" +``` + +## unixEpoch + +Returns the seconds since the unix epoch for a `time.Time`. + +``` +now | unixEpoch +``` + +## dateModify, mustDateModify + +The `dateModify` takes a modification and a date and returns the timestamp. + +Subtract an hour and thirty minutes from the current time: + +``` +now | date_modify "-1.5h" +``` + +If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. + +## htmlDate + +The `htmlDate` function formats a date for inserting into an HTML date picker +input field. + +``` +now | htmlDate +``` + +## htmlDateInZone + +Same as htmlDate, but with a timezone. + +``` +htmlDateInZone (now) "UTC" +``` + +## toDate, mustToDate + +`toDate` converts a string to a date. The first argument is the date layout and +the second the date string. If the string can't be convert it returns the zero +value. +`mustToDate` will return an error in case the string cannot be converted. + +This is useful when you want to convert a string date to another format +(using pipe). The example below converts "2017-12-31" to "31/12/2017". + +``` +toDate "2006-01-02" "2017-12-31" | date "02/01/2006" +``` +# Default Functions + +Sprig provides tools for setting default values for templates. + +## default + +To set a simple default value, use `default`: + +``` +default "foo" .Bar +``` + +In the above, if `.Bar` evaluates to a non-empty value, it will be used. But if +it is empty, `foo` will be returned instead. + +The definition of "empty" depends on type: + +- Numeric: 0 +- String: "" +- Lists: `[]` +- Dicts: `{}` +- Boolean: `false` +- And always `nil` (aka null) + +For structs, there is no definition of empty, so a struct will never return the +default. + +## empty + +The `empty` function returns `true` if the given value is considered empty, and +`false` otherwise. The empty values are listed in the `default` section. + +``` +empty .Foo +``` + +Note that in Go template conditionals, emptiness is calculated for you. Thus, +you rarely need `if empty .Foo`. Instead, just use `if .Foo`. + +## coalesce + +The `coalesce` function takes a list of values and returns the first non-empty +one. + +``` +coalesce 0 1 2 +``` + +The above returns `1`. + +This function is useful for scanning through multiple variables or values: + +``` +coalesce .name .parent.name "Matt" +``` + +The above will first check to see if `.name` is empty. If it is not, it will return +that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. +Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. + +## all + +The `all` function takes a list of values and returns true if all values are non-empty. + +``` +all 0 1 2 +``` + +The above returns `false`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Method "POST") +``` + +The above will check http.Request is POST with tls 1.3 and http/2. + +## any + +The `any` function takes a list of values and returns true if any value is non-empty. + +``` +any 0 1 2 +``` + +The above returns `true`. + +This function is useful for evaluating multiple conditions of variables or values: + +``` +any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method "OPTIONS") +``` + +The above will check http.Request method is one of GET/POST/OPTIONS. + +## fromJSON, mustFromJSON + +`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. +`mustFromJSON` will return an error in case the JSON is invalid. + +``` +fromJSON "{\"foo\": 55}" +``` + +## toJSON, mustToJSON + +The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. +`mustToJSON` will return an error in case the item cannot be encoded in JSON. + +``` +toJSON .Item +``` + +The above returns JSON string representation of `.Item`. + +## toPrettyJSON, mustToPrettyJSON + +The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. + +``` +toPrettyJSON .Item +``` + +The above returns indented JSON string representation of `.Item`. + +## toRawJSON, mustToRawJSON + +The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. + +``` +toRawJSON .Item +``` + +The above returns unescaped JSON string representation of `.Item`. + +## ternary + +The `ternary` function takes two values, and a test value. If the test value is +true, the first value will be returned. If the test value is empty, the second +value will be returned. This is similar to the c ternary operator. + +### true test value + +``` +ternary "foo" "bar" true +``` + +or + +``` +true | ternary "foo" "bar" +``` + +The above returns `"foo"`. + +### false test value + +``` +ternary "foo" "bar" false +``` + +or + +``` +false | ternary "foo" "bar" +``` + +The above returns `"bar"`. +# Encoding Functions + +Sprig has the following encoding and decoding functions: + +- `b64enc`/`b64dec`: Encode or decode with Base64 +- `b32enc`/`b32dec`: Encode or decode with Base32 +# Lists and List Functions + +Sprig provides a simple `list` type that can contain arbitrary sequential lists +of data. This is similar to arrays or slices, but lists are designed to be used +as immutable data types. + +Create a list of integers: + +``` +$myList := list 1 2 3 4 5 +``` + +The above creates a list of `[1 2 3 4 5]`. + +## first, mustFirst + +To get the head item on a list, use `first`. + +`first $myList` returns `1` + +`first` panics if there is a problem while `mustFirst` returns an error to the +template engine if there is a problem. + +## rest, mustRest + +To get the tail of the list (everything but the first item), use `rest`. + +`rest $myList` returns `[2 3 4 5]` + +`rest` panics if there is a problem while `mustRest` returns an error to the +template engine if there is a problem. + +## last, mustLast + +To get the last item on a list, use `last`: + +`last $myList` returns `5`. This is roughly analogous to reversing a list and +then calling `first`. + +`last` panics if there is a problem while `mustLast` returns an error to the +template engine if there is a problem. + +## initial, mustInitial + +This compliments `last` by returning all _but_ the last element. +`initial $myList` returns `[1 2 3 4]`. + +`initial` panics if there is a problem while `mustInitial` returns an error to the +template engine if there is a problem. + +## append, mustAppend + +Append a new item to an existing list, creating a new list. + +``` +$new = append $myList 6 +``` + +The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. + +`append` panics if there is a problem while `mustAppend` returns an error to the +template engine if there is a problem. + +## prepend, mustPrepend + +Push an element onto the front of a list, creating a new list. + +``` +prepend $myList 0 +``` + +The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. + +`prepend` panics if there is a problem while `mustPrepend` returns an error to the +template engine if there is a problem. + +## concat + +Concatenate arbitrary number of lists into one. + +``` +concat $myList ( list 6 7 ) ( list 8 ) +``` + +The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. + +## reverse, mustReverse + +Produce a new list with the reversed elements of the given list. + +``` +reverse $myList +``` + +The above would generate the list `[5 4 3 2 1]`. + +`reverse` panics if there is a problem while `mustReverse` returns an error to the +template engine if there is a problem. + +## uniq, mustUniq + +Generate a list with all of the duplicates removed. + +``` +list 1 1 1 2 | uniq +``` + +The above would produce `[1 2]` + +`uniq` panics if there is a problem while `mustUniq` returns an error to the +template engine if there is a problem. + +## without, mustWithout + +The `without` function filters items out of a list. + +``` +without $myList 3 +``` + +The above would produce `[1 2 4 5]` + +Without can take more than one filter: + +``` +without $myList 1 3 5 +``` + +That would produce `[2 4]` + +`without` panics if there is a problem while `mustWithout` returns an error to the +template engine if there is a problem. + +## has, mustHas + +Test to see if a list has a particular element. + +``` +has 4 $myList +``` + +The above would return `true`, while `has "hello" $myList` would return false. + +`has` panics if there is a problem while `mustHas` returns an error to the +template engine if there is a problem. + +## compact, mustCompact + +Accepts a list and removes entries with empty values. + +``` +$list := list 1 "a" "foo" "" +$copy := compact $list +``` + +`compact` will return a new list with the empty (i.e., "") item removed. + +`compact` panics if there is a problem and `mustCompact` returns an error to the +template engine if there is a problem. + +## slice, mustSlice + +To get partial elements of a list, use `slice list [n] [m]`. It is +equivalent of `list[n:m]`. + +- `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. +- `slice $myList 3` returns `[4 5]`. It is same as `myList[3:]`. +- `slice $myList 1 3` returns `[2 3]`. It is same as `myList[1:3]`. +- `slice $myList 0 3` returns `[1 2 3]`. It is same as `myList[:3]`. + +`slice` panics if there is a problem while `mustSlice` returns an error to the +template engine if there is a problem. + +## chunk + +To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. + +``` +chunk 3 (list 1 2 3 4 5 6 7 8) +``` + +This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. + +## A Note on List Internals + +A list is implemented in Go as a `[]interface{}`. For Go developers embedding +Sprig, you may pass `[]interface{}` items into your template context and be +able to use all of the `list` functions on those items. +# Dictionaries and Dict Functions + +Sprig provides a key/value storage type called a `dict` (short for "dictionary", +as in Python). A `dict` is an _unorder_ type. + +The key to a dictionary **must be a string**. However, the value can be any +type, even another `dict` or `list`. + +Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will +modify the contents of a dictionary. + +## dict + +Creating dictionaries is done by calling the `dict` function and passing it a +list of pairs. + +The following creates a dictionary with three items: + +``` +$myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" +``` + +## get + +Given a map and a key, get the value from the map. + +``` +get $myDict "name1" +``` + +The above returns `"value1"` + +Note that if the key is not found, this operation will simply return `""`. No error +will be generated. + +## set + +Use `set` to add a new key/value pair to a dictionary. + +``` +$_ := set $myDict "name4" "value4" +``` + +Note that `set` _returns the dictionary_ (a requirement of Go template functions), +so you may need to trap the value as done above with the `$_` assignment. + +## unset + +Given a map and a key, delete the key from the map. + +``` +$_ := unset $myDict "name4" +``` + +As with `set`, this returns the dictionary. + +Note that if the key is not found, this operation will simply return. No error +will be generated. + +## hasKey + +The `hasKey` function returns `true` if the given dict contains the given key. + +``` +hasKey $myDict "name1" +``` + +If the key is not found, this returns `false`. + +## pluck + +The `pluck` function makes it possible to give one key and multiple maps, and +get a list of all of the matches: + +``` +pluck "name1" $myDict $myOtherDict +``` + +The above will return a `list` containing every found value (`[value1 otherValue1]`). + +If the give key is _not found_ in a map, that map will not have an item in the +list (and the length of the returned list will be less than the number of dicts +in the call to `pluck`. + +If the key is _found_ but the value is an empty value, that value will be +inserted. + +A common idiom in Sprig templates is to uses `pluck... | first` to get the first +matching key out of a collection of dictionaries. + +## dig + +The `dig` function traverses a nested set of dicts, selecting keys from a list +of values. It returns a default value if any of the keys are not found at the +associated dict. + +``` +dig "user" "role" "humanName" "guest" $dict +``` + +Given a dict structured like +``` +{ + user: { + role: { + humanName: "curator" + } + } +} +``` + +the above would return `"curator"`. If the dict lacked even a `user` field, +the result would be `"guest"`. + +Dig can be very useful in cases where you'd like to avoid guard clauses, +especially since Go's template package's `and` doesn't shortcut. For instance +`and a.maybeNil a.maybeNil.iNeedThis` will always evaluate +`a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) + +`dig` accepts its dict argument last in order to support pipelining. + +## keys + +The `keys` function will return a `list` of all of the keys in one or more `dict` +types. Since a dictionary is _unordered_, the keys will not be in a predictable order. +They can be sorted with `sortAlpha`. + +``` +keys $myDict | sortAlpha +``` + +When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq` +function along with `sortAlpha` to get a unqiue, sorted list of keys. + +``` +keys $myDict $myOtherDict | uniq | sortAlpha +``` + +## pick + +The `pick` function selects just the given keys out of a dictionary, creating a +new `dict`. + +``` +$new := pick $myDict "name1" "name2" +``` + +The above returns `{name1: value1, name2: value2}` + +## omit + +The `omit` function is similar to `pick`, except it returns a new `dict` with all +the keys that _do not_ match the given keys. + +``` +$new := omit $myDict "name1" "name3" +``` + +The above returns `{name2: value2}` + +## values + +The `values` function is similar to `keys`, except it returns a new `list` with +all the values of the source `dict` (only one dictionary is supported). + +``` +$vals := values $myDict +``` + +The above returns `list["value1", "value2", "value 3"]`. Note that the `values` +function gives no guarantees about the result ordering- if you care about this, +then use `sortAlpha`. +# Type Conversion Functions + +The following type conversion functions are provided by Sprig: + +- `atoi`: Convert a string to an integer. +- `float64`: Convert to a `float64`. +- `int`: Convert to an `int` at the system's width. +- `int64`: Convert to an `int64`. +- `toDecimal`: Convert a unix octal to a `int64`. +- `toString`: Convert to a string. +- `toStrings`: Convert a list, slice, or array to a list of strings. + +Only `atoi` requires that the input be a specific type. The others will attempt +to convert from any type to the destination type. For example, `int64` can convert +floats to ints, and it can also convert strings to ints. + +## toStrings + +Given a list-like collection, produce a slice of strings. + +``` +list 1 2 3 | toStrings +``` + +The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns +them as a list. + +## toDecimal + +Given a unix octal permission, produce a decimal. + +``` +"0777" | toDecimal +``` + +The above converts `0777` to `511` and returns the value as an int64. +# Path and Filepath Functions + +While Sprig does not grant access to the filesystem, it does provide functions +for working with strings that follow file path conventions. + +## Paths + +Paths separated by the slash character (`/`), processed by the `path` package. + +Examples: + +* The [Linux](https://en.wikipedia.org/wiki/Linux) and + [MacOS](https://en.wikipedia.org/wiki/MacOS) + [filesystems](https://en.wikipedia.org/wiki/File_system): + `/home/user/file`, `/etc/config`; +* The path component of + [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): + `https://example.com/some/content/`, `ftp://example.com/file/`. + +### base + +Return the last element of a path. + +``` +base "foo/bar/baz" +``` + +The above prints "baz". + +### dir + +Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` +returns `foo/bar`. + +### clean + +Clean up a path. + +``` +clean "foo/bar/../baz" +``` + +The above resolves the `..` and returns `foo/baz`. + +### ext + +Return the file extension. + +``` +ext "foo.bar" +``` + +The above returns `.bar`. + +### isAbs + +To check whether a path is absolute, use `isAbs`. + +## Filepaths + +Paths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package. + +These are the recommended functions to use when parsing paths of local filesystems, usually when dealing with local files, directories, etc. + +Examples: + +* Running on Linux or MacOS the filesystem path is separated by the slash character (`/`): + `/home/user/file`, `/etc/config`; +* Running on [Windows](https://en.wikipedia.org/wiki/Microsoft_Windows) + the filesystem path is separated by the backslash character (`\`): + `C:\Users\Username\`, `C:\Program Files\Application\`; + +### osBase + +Return the last element of a filepath. + +``` +osBase "/foo/bar/baz" +osBase "C:\\foo\\bar\\baz" +``` + +The above prints "baz" on Linux and Windows, respectively. + +### osDir + +Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` +returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` +returns `C:\\foo\\bar` on Windows. + +### osClean + +Clean up a path. + +``` +osClean "/foo/bar/../baz" +osClean "C:\\foo\\bar\\..\\baz" +``` + +The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. + +### osExt + +Return the file extension. + +``` +osExt "/foo.bar" +osExt "C:\\foo.bar" +``` + +The above returns `.bar` on Linux and Windows, respectively. + +### osIsAbs + +To check whether a file path is absolute, use `osIsAbs`. +# Flow Control Functions + +## fail + +Unconditionally returns an empty `string` and an `error` with the specified +text. This is useful in scenarios where other conditionals have determined that +template rendering should fail. + +``` +fail "Please accept the end user license agreement" +``` +# UUID Functions + +Sprig can generate UUID v4 universally unique IDs. + +``` +uuidv4 +``` + +The above returns a new UUID of the v4 (randomly generated) type. +# Reflection Functions + +Sprig provides rudimentary reflection tools. These help advanced template +developers understand the underlying Go type information for a particular value. + +Go has several primitive _kinds_, like `string`, `slice`, `int64`, and `bool`. + +Go has an open _type_ system that allows developers to create their own types. + +Sprig provides a set of functions for each. + +## Kind Functions + +There are two Kind functions: `kindOf` returns the kind of an object. + +``` +kindOf "hello" +``` + +The above would return `string`. For simple tests (like in `if` blocks), the +`kindIs` function will let you verify that a value is a particular kind: + +``` +kindIs "int" 123 +``` + +The above will return `true` + +## Type Functions + +Types are slightly harder to work with, so there are three different functions: + +- `typeOf` returns the underlying type of a value: `typeOf $foo` +- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` +- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. + +**Note:** None of these can test whether or not something implements a given +interface, since doing so would require compiling the interface in ahead of time. + +## deepEqual + +`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) + +Works for non-primitive types as well (compared to the built-in `eq`). + +``` +deepEqual (list 1 2 3) (list 1 2 3) +``` + +The above will return `true` +# Cryptographic and Security Functions + +Sprig provides a couple of advanced cryptographic functions. + +## sha1sum + +The `sha1sum` function receives a string, and computes it's SHA1 digest. + +``` +sha1sum "Hello world!" +``` + +## sha256sum + +The `sha256sum` function receives a string, and computes it's SHA256 digest. + +``` +sha256sum "Hello world!" +``` + +The above will compute the SHA 256 sum in an "ASCII armored" format that is +safe to print. + +## sha512sum + +The `sha512sum` function receives a string, and computes it's SHA512 digest. + +``` +sha512sum "Hello world!" +``` + +The above will compute the SHA 512 sum in an "ASCII armored" format that is +safe to print. + +## adler32sum + +The `adler32sum` function receives a string, and computes its Adler-32 checksum. + +``` +adler32sum "Hello world!" +``` +# URL Functions + +## urlParse +Parses string for URL and produces dict with URL parts + +``` +urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" +``` + +The above returns a dict, containing URL object: +```yaml +scheme: 'http' +host: 'server.com:8080' +path: '/api' +query: 'list=false' +opaque: nil +fragment: 'anchor' +userinfo: 'admin:secret' +``` + +For more info, check https://golang.org/pkg/net/url/#URL + +## urlJoin +Joins map (produced by `urlParse`) to produce URL string + +``` +urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") +``` + +The above returns the following string: +``` +proto://host:80/path?query#fragment +``` diff --git a/server/server_test.go b/server/server_test.go index 4fa059b6..a783dbd2 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "crypto/rand" + _ "embed" "encoding/base64" "encoding/json" "fmt" @@ -3069,6 +3070,23 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) } +var ( + //go:embed testdata/webhook_github_comment_created.json + githubCommentCreatedJSON string +) + +func TestServer_MessageTemplate_FromNamedTemplate(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", githubCommentCreatedJSON, map[string]string{ + "Template": "github", + }) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "💬 New comment on issue #1389 — instant alerts without Pull to refresh", m.Title) + require.Equal(t, "💬 New comment on issue #1389 — instant alerts without Pull to refresh", m.Message) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/server/templates/github.yml b/server/templates/github.yml index 54a17e9b..92f3ab13 100644 --- a/server/templates/github.yml +++ b/server/templates/github.yml @@ -1,23 +1,32 @@ +title: | + {{- if .pull_request }} + Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }} + {{- else if and .starred_at (eq .action "created")}} + ⭐ {{ .sender.login }} starred {{ .repository.full_name }} + {{- else if and .comment (eq .action "created") }} + 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} + {{- else }} + Unsupported GitHub event type or action. + {{- end }} message: | - {{- if .pull_request }} - 🔀 PR {{ .action }}: #{{ .pull_request.number }} — {{ .pull_request.title }} - 📦 {{ .repository.full_name }} - 👤 {{ .pull_request.user.login }} - 🌿 {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} - 🔗 {{ .pull_request.html_url }} - 📝 {{ .pull_request.body | default "(no description)" }} - {{- else if and .starred_at (eq .action "created")}} - ⭐ {{ .sender.login }} starred {{ .repository.full_name }} - 📦 {{ .repository.description | default "(no description)" }} - 🔗 {{ .repository.html_url }} - 📅 {{ .starred_at }} - {{- else if and .comment (eq .action "created") }} - 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} - 📦 {{ .repository.full_name }} - 👤 {{ .comment.user.login }} - 🔗 {{ .comment.html_url }} - 📝 {{ .comment.body | default "(no comment body)" }} - {{- else }} - {{ fail "Unsupported GitHub event type or action." }} - {{- end }} + {{- if .pull_request }} + Repository: {{ .repository.full_name }}, branch {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} + Created by: {{ .pull_request.user.login }} + Link: {{ .pull_request.html_url }} + {{ if .pull_request.body }}Description: + {{ .pull_request.body }}{{ end }} + {{- else if and .starred_at (eq .action "created")}} + ⭐ {{ .sender.login }} starred {{ .repository.full_name }} + 📦 {{ .repository.description | default "(no description)" }} + 🔗 {{ .repository.html_url }} + 📅 {{ .starred_at }} + {{- else if and .comment (eq .action "created") }} + 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} + 📦 {{ .repository.full_name }} + 👤 {{ .comment.user.login }} + 🔗 {{ .comment.html_url }} + 📝 {{ .comment.body | default "(no comment body)" }} + {{- else }} + {{ fail "Unsupported GitHub event type or action." }} + {{- end }} diff --git a/server/testdata/webhook_github_comment_created.json b/server/testdata/webhook_github_comment_created.json new file mode 100644 index 00000000..04e7cddb --- /dev/null +++ b/server/testdata/webhook_github_comment_created.json @@ -0,0 +1,261 @@ +{ + "action": "created", + "issue": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389", + "repository_url": "https://api.github.com/repos/binwiederhier/ntfy", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/labels{/name}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/comments", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/events", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1389", + "id": 3230655753, + "node_id": "I_kwDOGRBhi87Aj-UJ", + "number": 1389, + "title": "instant alerts without Pull to refresh", + "user": { + "login": "edbraunh", + "id": 8795846, + "node_id": "MDQ6VXNlcjg3OTU4NDY=", + "avatar_url": "https://avatars.githubusercontent.com/u/8795846?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/edbraunh", + "html_url": "https://github.com/edbraunh", + "followers_url": "https://api.github.com/users/edbraunh/followers", + "following_url": "https://api.github.com/users/edbraunh/following{/other_user}", + "gists_url": "https://api.github.com/users/edbraunh/gists{/gist_id}", + "starred_url": "https://api.github.com/users/edbraunh/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/edbraunh/subscriptions", + "organizations_url": "https://api.github.com/users/edbraunh/orgs", + "repos_url": "https://api.github.com/users/edbraunh/repos", + "events_url": "https://api.github.com/users/edbraunh/events{/privacy}", + "received_events_url": "https://api.github.com/users/edbraunh/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 3480884105, + "node_id": "LA_kwDOGRBhi87PehOJ", + "url": "https://api.github.com/repos/binwiederhier/ntfy/labels/enhancement", + "name": "enhancement", + "color": "a2eeef", + "default": true, + "description": "New feature or request" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + ], + "milestone": null, + "comments": 3, + "created_at": "2025-07-15T03:46:30Z", + "updated_at": "2025-07-16T11:45:57Z", + "closed_at": null, + "author_association": "NONE", + "active_lock_reason": null, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "body": "Hello ntfy Team,\n\nFirst off, thank you for developing such a powerful and lightweight notification app — it’s been invaluable for receiving timely alerts.\n\nI’m a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\n\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\n\nThank you for considering this request. I appreciate your hard work and look forward to future updates!", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "comment": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289", + "issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389", + "id": 3078214289, + "node_id": "IC_kwDOGRBhi863edKR", + "user": { + "login": "wunter8", + "id": 8421688, + "node_id": "MDQ6VXNlcjg0MjE2ODg=", + "avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wunter8", + "html_url": "https://github.com/wunter8", + "followers_url": "https://api.github.com/users/wunter8/followers", + "following_url": "https://api.github.com/users/wunter8/following{/other_user}", + "gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wunter8/subscriptions", + "organizations_url": "https://api.github.com/users/wunter8/orgs", + "repos_url": "https://api.github.com/users/wunter8/repos", + "events_url": "https://api.github.com/users/wunter8/events{/privacy}", + "received_events_url": "https://api.github.com/users/wunter8/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "created_at": "2025-07-16T11:45:57Z", + "updated_at": "2025-07-16T11:45:57Z", + "author_association": "CONTRIBUTOR", + "body": "These are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "performed_via_github_app": null + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-13T13:56:19Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 367, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 367, + "watchers": 25111, + "default_branch": "main" + }, + "sender": { + "login": "wunter8", + "id": 8421688, + "node_id": "MDQ6VXNlcjg0MjE2ODg=", + "avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/wunter8", + "html_url": "https://github.com/wunter8", + "followers_url": "https://api.github.com/users/wunter8/followers", + "following_url": "https://api.github.com/users/wunter8/following{/other_user}", + "gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}", + "starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/wunter8/subscriptions", + "organizations_url": "https://api.github.com/users/wunter8/orgs", + "repos_url": "https://api.github.com/users/wunter8/repos", + "events_url": "https://api.github.com/users/wunter8/events{/privacy}", + "received_events_url": "https://api.github.com/users/wunter8/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_github_pr_opened.json b/server/testdata/webhook_github_pr_opened.json new file mode 100644 index 00000000..c89d1c3b --- /dev/null +++ b/server/testdata/webhook_github_pr_opened.json @@ -0,0 +1,541 @@ +{ + "action": "opened", + "number": 1390, + "pull_request": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390", + "id": 2670425869, + "node_id": "PR_kwDOGRBhi86fK3cN", + "html_url": "https://github.com/binwiederhier/ntfy/pull/1390", + "diff_url": "https://github.com/binwiederhier/ntfy/pull/1390.diff", + "patch_url": "https://github.com/binwiederhier/ntfy/pull/1390.patch", + "issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390", + "number": 1390, + "state": "open", + "locked": false, + "title": "WIP Template dir", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "body": null, + "created_at": "2025-07-16T11:49:31Z", + "updated_at": "2025-07-16T11:49:31Z", + "closed_at": null, + "merged_at": null, + "merge_commit_sha": null, + "assignee": null, + "assignees": [ + ], + "requested_reviewers": [ + ], + "requested_teams": [ + ], + "labels": [ + ], + "milestone": null, + "draft": false, + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits", + "review_comments_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments", + "review_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd", + "head": { + "label": "binwiederhier:template-dir", + "ref": "template-dir", + "sha": "b1e935da45365c5e7e731d544a1ad4c7ea3643cd", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "repo": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": true, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "base": { + "label": "binwiederhier:main", + "ref": "main", + "sha": "81a486adc11fe24efcbedefb28ae946028597c2f", + "user": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "repo": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main", + "allow_squash_merge": true, + "allow_merge_commit": true, + "allow_rebase_merge": true, + "allow_auto_merge": true, + "delete_branch_on_merge": false, + "allow_update_branch": false, + "use_squash_pr_title_as_default": false, + "squash_merge_commit_message": "COMMIT_MESSAGES", + "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", + "merge_commit_message": "PR_TITLE", + "merge_commit_title": "MERGE_MESSAGE" + } + }, + "_links": { + "self": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390" + }, + "html": { + "href": "https://github.com/binwiederhier/ntfy/pull/1390" + }, + "issue": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390" + }, + "comments": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments" + }, + "review_comments": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments" + }, + "review_comment": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}" + }, + "commits": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits" + }, + "statuses": { + "href": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd" + } + }, + "author_association": "OWNER", + "auto_merge": null, + "active_lock_reason": null, + "merged": false, + "mergeable": null, + "rebaseable": null, + "mergeable_state": "unknown", + "merged_by": null, + "comments": 0, + "review_comments": 0, + "maintainer_can_modify": false, + "commits": 7, + "additions": 5506, + "deletions": 42, + "changed_files": 58 + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T10:18:34Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36740, + "stargazers_count": 25111, + "watchers_count": 25111, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25111, + "default_branch": "main" + }, + "sender": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/server/testdata/webhook_github_star_created.json b/server/testdata/webhook_github_star_created.json new file mode 100644 index 00000000..30099145 --- /dev/null +++ b/server/testdata/webhook_github_star_created.json @@ -0,0 +1,141 @@ +{ + "action": "created", + "starred_at": "2025-07-16T12:57:43Z", + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T12:57:43Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "mbilby", + "id": 51273322, + "node_id": "MDQ6VXNlcjUxMjczMzIy", + "avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/mbilby", + "html_url": "https://github.com/mbilby", + "followers_url": "https://api.github.com/users/mbilby/followers", + "following_url": "https://api.github.com/users/mbilby/following{/other_user}", + "gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mbilby/subscriptions", + "organizations_url": "https://api.github.com/users/mbilby/orgs", + "repos_url": "https://api.github.com/users/mbilby/repos", + "events_url": "https://api.github.com/users/mbilby/events{/privacy}", + "received_events_url": "https://api.github.com/users/mbilby/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} + diff --git a/server/testdata/webhook_github_watch_created.json b/server/testdata/webhook_github_watch_created.json new file mode 100644 index 00000000..47440ebf --- /dev/null +++ b/server/testdata/webhook_github_watch_created.json @@ -0,0 +1,139 @@ +{ + "action": "started", + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T12:57:43Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 368, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 368, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "mbilby", + "id": 51273322, + "node_id": "MDQ6VXNlcjUxMjczMzIy", + "avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/mbilby", + "html_url": "https://github.com/mbilby", + "followers_url": "https://api.github.com/users/mbilby/followers", + "following_url": "https://api.github.com/users/mbilby/following{/other_user}", + "gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}", + "starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/mbilby/subscriptions", + "organizations_url": "https://api.github.com/users/mbilby/orgs", + "repos_url": "https://api.github.com/users/mbilby/repos", + "events_url": "https://api.github.com/users/mbilby/events{/privacy}", + "received_events_url": "https://api.github.com/users/mbilby/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} From 4603802f62bdb6495c159d33c9dfc80801204159 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 16 Jul 2025 21:50:29 +0200 Subject: [PATCH 44/87] WIP --- docs/template-functions.md | 1093 +++++++++++------ server/server.go | 31 +- server/templates/github.yml | 69 +- .../testdata/webhook_github_issue_opened.json | 216 ++++ util/sprig/functions.go | 43 +- 5 files changed, 994 insertions(+), 458 deletions(-) create mode 100644 server/testdata/webhook_github_issue_opened.json diff --git a/docs/template-functions.md b/docs/template-functions.md index 75c0e7c4..7c9593e6 100644 --- a/docs/template-functions.md +++ b/docs/template-functions.md @@ -1,90 +1,128 @@ -# String Functions +# Template functions + +## Table of Contents + +- [String Functions](#string-functions) +- [String List Functions](#string-list-functions) +- [Integer Math Functions](#integer-math-functions) +- [Integer List Functions](#integer-list-functions) +- [Date Functions](#date-functions) +- [Default Functions](#default-functions) +- [Encoding Functions](#encoding-functions) +- [Lists and List Functions](#lists-and-list-functions) +- [Dictionaries and Dict Functions](#dictionaries-and-dict-functions) +- [Type Conversion Functions](#type-conversion-functions) +- [Path and Filepath Functions](#path-and-filepath-functions) +- [Flow Control Functions](#flow-control-functions) +- [UUID Functions](#uuid-functions) +- [Reflection Functions](#reflection-functions) +- [Cryptographic and Security Functions](#cryptographic-and-security-functions) +- [URL Functions](#url-functions) + +## String Functions Sprig has a number of string manipulation functions. -## trim - -The `trim` function removes space from either side of a string: + + + + + -## trimAll - -Remove given characters from the front or back of a string: + + + + -## trimSuffix - -Trim just the suffix from a string: + + + + -## trimPrefix - -Trim just the prefix from a string: + + + + -## upper - -Convert the entire string to uppercase: + + + + -## lower - -Convert the entire string to lowercase: + + + + -## title - -Convert to title case: + + + + -## repeat - -Repeat a string multiple times: + + + + -## substr - -Get a substring from a string. It takes three parameters: + + + + -## trunc - -Truncate a string (and add no suffix) + + + + -## contains - -Test to see if one string is contained inside of another: + + + + -## hasPrefix and hasSuffix - -The `hasPrefix` and `hasSuffix` functions test whether a string has a given + + + + -## quote and squote - -These functions wrap a string in double quotes (`quote`) or single quotes + + + + -## cat - -The `cat` function concatenates multiple strings together into one, separating + + + + -## indent - -The `indent` function indents every line in a given string to the specified + + + + -## nindent - -The `nindent` function is the same as the indent function, but prepends a new + + + + -## replace - -Perform simple string replacement. + + + + -## plural - -Pluralize a string. + + + + -## regexMatch, mustRegexMatch - -Returns true if the input string contains any match of the regular expression. + + + + -## regexFindAll, mustRegexFindAll - -Returns a slice of all matches of the regular expression in the input string. + + + + -## regexFind, mustRegexFind - -Return the first (left most) match of the regular expression in the input string + + + + -## regexReplaceAll, mustRegexReplaceAll - -Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. + + + + -## regexReplaceAllLiteral, mustRegexReplaceAllLiteral - -Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement + + + + -## regexSplit, mustRegexSplit - -Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches + + + + -## regexQuoteMeta - -Returns a string that escapes all regular expression metacharacters inside the argument text; + + + + +
trimThe `trim` function removes space from either side of a string: ``` trim " hello " ``` The above produces `hello` +
trimAllRemove given characters from the front or back of a string: ``` trimAll "$" "$5.00" ``` The above returns `5.00` (as a string). +
trimSuffixTrim just the suffix from a string: ``` trimSuffix "-" "hello-" ``` The above returns `hello` +
trimPrefixTrim just the prefix from a string: ``` trimPrefix "-" "-hello" ``` The above returns `hello` +
upperConvert the entire string to uppercase: ``` upper "hello" ``` The above returns `HELLO` +
lowerConvert the entire string to lowercase: ``` lower "HELLO" ``` The above returns `hello` +
titleConvert to title case: ``` title "hello world" ``` The above returns `Hello World` +
repeatRepeat a string multiple times: ``` repeat 3 "hello" ``` The above returns `hellohellohello` +
substrGet a substring from a string. It takes three parameters: - start (int) - end (int) @@ -95,10 +133,12 @@ substr 0 5 "hello world" ``` The above returns `hello` +
truncTruncate a string (and add no suffix) ``` trunc 5 "hello world" @@ -111,20 +151,24 @@ trunc -5 "hello world" ``` The above produces `world`. +
containsTest to see if one string is contained inside of another: ``` contains "cat" "catch" ``` The above returns `true` because `catch` contains `cat`. +
hasPrefix and hasSuffixThe `hasPrefix` and `hasSuffix` functions test whether a string has a given prefix or suffix: ``` @@ -132,15 +176,19 @@ hasPrefix "cat" "catch" ``` The above returns `true` because `catch` has the prefix `cat`. +
quote and squoteThese functions wrap a string in double quotes (`quote`) or single quotes (`squote`). +
catThe `cat` function concatenates multiple strings together into one, separating them with spaces: ``` @@ -148,10 +196,12 @@ cat "hello" "beautiful" "world" ``` The above produces `hello beautiful world` +
indentThe `indent` function indents every line in a given string to the specified indent width. This is useful when aligning multi-line strings: ``` @@ -159,10 +209,12 @@ indent 4 $lots_of_text ``` The above will indent every line of text by 4 space characters. +
nindentThe `nindent` function is the same as the indent function, but prepends a new line to the beginning of the string. ``` @@ -171,10 +223,12 @@ nindent 4 $lots_of_text The above will indent every line of text by 4 space characters and add a new line to the beginning. +
replacePerform simple string replacement. It takes three arguments: @@ -187,10 +241,12 @@ It takes three arguments: ``` The above will produce `I-Am-Henry-VIII` +
pluralPluralize a string. ``` len $fish | plural "one anchovy" "many anchovies" @@ -210,10 +266,12 @@ NOTE: Sprig does not currently support languages with more complex pluralization rules. And `0` is considered a plural because the English language treats it as such (`zero anchovies`). The Sprig developers are working on a solution for better internationalization. +
regexMatch, mustRegexMatchReturns true if the input string contains any match of the regular expression. ``` regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" @@ -223,10 +281,12 @@ The above produces `true` `regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the template engine if there is a problem. +
regexFindAll, mustRegexFindAllReturns a slice of all matches of the regular expression in the input string. The last parameter n determines the number of substrings to return, where -1 means return all matches ``` @@ -237,10 +297,12 @@ The above produces `[2 4 6 8]` `regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the template engine if there is a problem. +
regexFind, mustRegexFindReturn the first (left most) match of the regular expression in the input string ``` regexFind "[a-zA-Z][1-9]" "abcd1234" @@ -250,10 +312,12 @@ The above produces `d1` `regexFind` panics if there is a problem and `mustRegexFind` returns an error to the template engine if there is a problem. +
regexReplaceAll, mustRegexReplaceAllReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch ``` @@ -264,10 +328,12 @@ The above produces `-W-xxW-` `regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the template engine if there is a problem. +
regexReplaceAllLiteral, mustRegexReplaceAllLiteralReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement The replacement string is substituted directly, without using Expand ``` @@ -278,10 +344,12 @@ The above produces `-${1}-${1}-` `regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the template engine if there is a problem. +
regexSplit, mustRegexSplitSlices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches ``` regexSplit "z+" "pizza" -1 @@ -291,10 +359,12 @@ The above produces `[pi a]` `regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the template engine if there is a problem. +
regexQuoteMetaReturns a string that escapes all regular expression metacharacters inside the argument text; the returned string is a regular expression matching the literal text. ``` @@ -302,19 +372,22 @@ regexQuoteMeta "1.2.3" ``` The above produces `1\.2\.3` - -## See Also... +
The [Conversion Functions](conversion.md) contain functions for converting strings. The [String List Functions](string_slice.md) contains functions for working with an array of strings. -# String List Functions + +## String List Functions These function operate on or generate slices of strings. In Go, a slice is a growable array. In Sprig, it's a special case of a `list`. -## join - -Join a list of strings into a single string, with the given separator. + + + + + -## splitList and split - -Split a string into a list of strings: + + + + -## splitn - -`splitn` function splits a string into a `dict` with `n` keys. It is designed to make + + + + -## sortAlpha - -The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) + + + + +
joinJoin a list of strings into a single string, with the given separator. ``` list "hello" "world" | join "_" @@ -329,10 +402,12 @@ list 1 2 3 | join "+" ``` The above will produce `1+2+3` +
splitList and splitSplit a string into a list of strings: ``` splitList "$" "foo$bar$baz" @@ -354,10 +429,12 @@ $a._0 ``` The above produces `foo` +
splitn`splitn` function splits a string into a `dict` with `n` keys. It is designed to make it easy to use template dot notation for accessing members: ``` @@ -371,97 +448,132 @@ $a._0 ``` The above produces `foo` +
sortAlphaThe `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) order. It does _not_ sort in place, but returns a sorted copy of the list, in keeping with the immutability of lists. -# Integer Math Functions +
+ +## Integer Math Functions The following math functions operate on `int64` values. -## add - -Sum numbers with `add`. Accepts two or more inputs. + + + + + -## add1 + + + + -To increment by 1, use `add1` + + + + -## sub + + + + -To subtract, use `sub` + + + + -## div - -Perform integer division with `div` - -## mod - -Modulo with `mod` - -## mul - -Multiply with `mul`. Accepts two or more inputs. + + + + -## max - -Return the largest of a series of integers: + + + + -## min - -Return the smallest of a series of integers. + + + + -## floor - -Returns the greatest float value less than or equal to input value + + + + -## ceil - -Returns the greatest float value greater than or equal to input value + + + + -## round - -Returns a float value with the remainder rounded to the given number to digits after the decimal point. + + + + -## randInt -Returns a random integer value from min (inclusive) to max (exclusive). + + + + +
addSum numbers with `add`. Accepts two or more inputs. ``` add 1 2 3 ``` +
add1To increment by 1, use `add1` +
subTo subtract, use `sub` +
divPerform integer division with `div` +
modModulo with `mod` +
mulMultiply with `mul`. Accepts two or more inputs. ``` mul 1 2 3 ``` +
maxReturn the largest of a series of integers: This will return `3`: ``` max 1 2 3 ``` +
minReturn the smallest of a series of integers. `min 1 2 3` will return `1` +
floorReturns the greatest float value less than or equal to input value `floor 123.9999` will return `123.0` +
ceilReturns the greatest float value greater than or equal to input value `ceil 123.001` will return `124.0` +
roundReturns a float value with the remainder rounded to the given number to digits after the decimal point. `round 123.555555 3` will return `123.556` +
randIntReturns a random integer value from min (inclusive) to max (exclusive). ``` randInt 12 30 ``` The above will produce a random number in the range [12,30]. -# Integer List Functions +
-## until +## Integer List Functions -The `until` function builds a range of integers. + + + + + -## untilStep - -Like `until`, `untilStep` generates a list of counting integers. But it allows + + + + -## seq - -Works like the bash `seq` command. + + + + +
untilThe `until` function builds a range of integers. ``` until 5 @@ -470,10 +582,12 @@ until 5 The above generates the list `[0, 1, 2, 3, 4]`. This is useful for looping with `range $i, $e := until 5`. +
untilStepLike `until`, `untilStep` generates a list of counting integers. But it allows you to define a start, stop, and step: ``` @@ -482,10 +596,12 @@ untilStep 3 6 2 The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal or greater than 6. This is similar to Python's `range` function. +
seqWorks like the bash `seq` command. * 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. * 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. * 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. @@ -498,15 +614,22 @@ seq 2 -2 => 2 1 0 -1 -2 seq 0 2 10 => 0 2 4 6 8 10 seq 0 -2 -5 => 0 -2 -4 ``` -# Date Functions +
-## now +## Date Functions -The current date/time. Use this in conjunction with other date functions. + + + + + -## ago - -The `ago` function returns duration from time.Now in seconds resolution. + + + + -## date - -The `date` function formats a date. + + + + -## dateInZone - -Same as `date`, but with a timezone. + + + + -## duration - -Formats a given amount of seconds as a `time.Duration`. + + + + -## durationRound - -Rounds a given duration to the most significant unit. Strings and `time.Duration` + + + + -## unixEpoch - -Returns the seconds since the unix epoch for a `time.Time`. + + + + -## dateModify, mustDateModify - -The `dateModify` takes a modification and a date and returns the timestamp. + + + + -## htmlDate - -The `htmlDate` function formats a date for inserting into an HTML date picker + + + + -## htmlDateInZone - -Same as htmlDate, but with a timezone. + + + + -## toDate, mustToDate - -`toDate` converts a string to a date. The first argument is the date layout and + + + + +
nowThe current date/time. Use this in conjunction with other date functions. +
agoThe `ago` function returns duration from time.Now in seconds resolution. ``` ago .CreatedAt @@ -517,10 +640,12 @@ returns in `time.Duration` String() format ``` 2h34m7s ``` +
dateThe `date` function formats a date. Format the date to YEAR-MONTH-DAY: @@ -538,28 +663,34 @@ Mon Jan 2 15:04:05 MST 2006 Write it in the format you want. Above, `2006-01-02` is the same date, but in the format we want. +
dateInZoneSame as `date`, but with a timezone. ``` dateInZone "2006-01-02" (now) "UTC" ``` +
durationFormats a given amount of seconds as a `time.Duration`. This returns 1m35s ``` duration "95" ``` +
durationRoundRounds a given duration to the most significant unit. Strings and `time.Duration` gets parsed as a duration, while a `time.Time` is calculated as the duration since. This return 2h @@ -573,18 +704,22 @@ This returns 3mo ``` durationRound "2400h10m5s" ``` +
unixEpochReturns the seconds since the unix epoch for a `time.Time`. ``` now | unixEpoch ``` +
dateModify, mustDateModifyThe `dateModify` takes a modification and a date and returns the timestamp. Subtract an hour and thirty minutes from the current time: @@ -593,27 +728,33 @@ now | date_modify "-1.5h" ``` If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. +
htmlDateThe `htmlDate` function formats a date for inserting into an HTML date picker input field. ``` now | htmlDate ``` +
htmlDateInZoneSame as htmlDate, but with a timezone. ``` htmlDateInZone (now) "UTC" ``` +
toDate, mustToDate`toDate` converts a string to a date. The first argument is the date layout and the second the date string. If the string can't be convert it returns the zero value. `mustToDate` will return an error in case the string cannot be converted. @@ -624,13 +765,18 @@ This is useful when you want to convert a string date to another format ``` toDate "2006-01-02" "2017-12-31" | date "02/01/2006" ``` -# Default Functions +
+ +## Default Functions Sprig provides tools for setting default values for templates. -## default - -To set a simple default value, use `default`: + + + + + -## empty - -The `empty` function returns `true` if the given value is considered empty, and + + + + -## coalesce - -The `coalesce` function takes a list of values and returns the first non-empty + + + + -## all - -The `all` function takes a list of values and returns true if all values are non-empty. + + + + -## any - -The `any` function takes a list of values and returns true if any value is non-empty. + + + + -## fromJSON, mustFromJSON - -`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. + + + + -## toJSON, mustToJSON - -The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. + + + + -## toPrettyJSON, mustToPrettyJSON - -The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. + + + + -## toRawJSON, mustToRawJSON - -The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. + + + + -## ternary - -The `ternary` function takes two values, and a test value. If the test value is + + + + +
defaultTo set a simple default value, use `default`: ``` default "foo" .Bar @@ -650,10 +796,12 @@ The definition of "empty" depends on type: For structs, there is no definition of empty, so a struct will never return the default. +
emptyThe `empty` function returns `true` if the given value is considered empty, and `false` otherwise. The empty values are listed in the `default` section. ``` @@ -662,10 +810,12 @@ empty .Foo Note that in Go template conditionals, emptiness is calculated for you. Thus, you rarely need `if empty .Foo`. Instead, just use `if .Foo`. +
coalesceThe `coalesce` function takes a list of values and returns the first non-empty one. ``` @@ -683,10 +833,12 @@ coalesce .name .parent.name "Matt" The above will first check to see if `.name` is empty. If it is not, it will return that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. +
allThe `all` function takes a list of values and returns true if all values are non-empty. ``` all 0 1 2 @@ -701,10 +853,12 @@ all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Me ``` The above will check http.Request is POST with tls 1.3 and http/2. +
anyThe `any` function takes a list of values and returns true if any value is non-empty. ``` any 0 1 2 @@ -719,19 +873,23 @@ any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method " ``` The above will check http.Request method is one of GET/POST/OPTIONS. +
fromJSON, mustFromJSON`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. `mustFromJSON` will return an error in case the JSON is invalid. ``` fromJSON "{\"foo\": 55}" ``` +
toJSON, mustToJSONThe `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. `mustToJSON` will return an error in case the item cannot be encoded in JSON. ``` @@ -739,30 +897,36 @@ toJSON .Item ``` The above returns JSON string representation of `.Item`. +
toPrettyJSON, mustToPrettyJSONThe `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. ``` toPrettyJSON .Item ``` The above returns indented JSON string representation of `.Item`. +
toRawJSON, mustToRawJSONThe `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. ``` toRawJSON .Item ``` The above returns unescaped JSON string representation of `.Item`. +
ternaryThe `ternary` function takes two values, and a test value. If the test value is true, the first value will be returned. If the test value is empty, the second value will be returned. This is similar to the c ternary operator. @@ -793,13 +957,29 @@ false | ternary "foo" "bar" ``` The above returns `"bar"`. -# Encoding Functions +
+ +## Encoding Functions Sprig has the following encoding and decoding functions: -- `b64enc`/`b64dec`: Encode or decode with Base64 -- `b32enc`/`b32dec`: Encode or decode with Base32 -# Lists and List Functions + + + + + + + + + + +
b64enc/b64decEncode or decode with Base64 +
b32enc/b32decEncode or decode with Base32 +
+ +## Lists and List Functions Sprig provides a simple `list` type that can contain arbitrary sequential lists of data. This is similar to arrays or slices, but lists are designed to be used @@ -813,45 +993,54 @@ $myList := list 1 2 3 4 5 The above creates a list of `[1 2 3 4 5]`. -## first, mustFirst - -To get the head item on a list, use `first`. + + + + + -## rest, mustRest - -To get the tail of the list (everything but the first item), use `rest`. + + + + -## last, mustLast - -To get the last item on a list, use `last`: + + + + -## initial, mustInitial - -This compliments `last` by returning all _but_ the last element. + + + + -## append, mustAppend - -Append a new item to an existing list, creating a new list. + + + + -## prepend, mustPrepend - -Push an element onto the front of a list, creating a new list. + + + + -## concat - -Concatenate arbitrary number of lists into one. + + + + -## reverse, mustReverse - -Produce a new list with the reversed elements of the given list. + + + + -## uniq, mustUniq - -Generate a list with all of the duplicates removed. + + + + -## without, mustWithout - -The `without` function filters items out of a list. + + + + -## has, mustHas - -Test to see if a list has a particular element. + + + + -## compact, mustCompact - -Accepts a list and removes entries with empty values. + + + + -## slice, mustSlice - -To get partial elements of a list, use `slice list [n] [m]`. It is + + + + -## chunk - -To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. + + + + +
first, mustFirstTo get the head item on a list, use `first`. `first $myList` returns `1` `first` panics if there is a problem while `mustFirst` returns an error to the template engine if there is a problem. +
rest, mustRestTo get the tail of the list (everything but the first item), use `rest`. `rest $myList` returns `[2 3 4 5]` `rest` panics if there is a problem while `mustRest` returns an error to the template engine if there is a problem. +
last, mustLastTo get the last item on a list, use `last`: `last $myList` returns `5`. This is roughly analogous to reversing a list and then calling `first`. `last` panics if there is a problem while `mustLast` returns an error to the template engine if there is a problem. +
initial, mustInitialThis compliments `last` by returning all _but_ the last element. `initial $myList` returns `[1 2 3 4]`. `initial` panics if there is a problem while `mustInitial` returns an error to the template engine if there is a problem. +
append, mustAppendAppend a new item to an existing list, creating a new list. ``` $new = append $myList 6 @@ -861,10 +1050,12 @@ The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. `append` panics if there is a problem while `mustAppend` returns an error to the template engine if there is a problem. +
prepend, mustPrependPush an element onto the front of a list, creating a new list. ``` prepend $myList 0 @@ -874,20 +1065,24 @@ The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. `prepend` panics if there is a problem while `mustPrepend` returns an error to the template engine if there is a problem. +
concatConcatenate arbitrary number of lists into one. ``` concat $myList ( list 6 7 ) ( list 8 ) ``` The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. +
reverse, mustReverseProduce a new list with the reversed elements of the given list. ``` reverse $myList @@ -897,10 +1092,12 @@ The above would generate the list `[5 4 3 2 1]`. `reverse` panics if there is a problem while `mustReverse` returns an error to the template engine if there is a problem. +
uniq, mustUniqGenerate a list with all of the duplicates removed. ``` list 1 1 1 2 | uniq @@ -910,10 +1107,12 @@ The above would produce `[1 2]` `uniq` panics if there is a problem while `mustUniq` returns an error to the template engine if there is a problem. +
without, mustWithoutThe `without` function filters items out of a list. ``` without $myList 3 @@ -931,10 +1130,12 @@ That would produce `[2 4]` `without` panics if there is a problem while `mustWithout` returns an error to the template engine if there is a problem. +
has, mustHasTest to see if a list has a particular element. ``` has 4 $myList @@ -944,10 +1145,12 @@ The above would return `true`, while `has "hello" $myList` would return false. `has` panics if there is a problem while `mustHas` returns an error to the template engine if there is a problem. +
compact, mustCompactAccepts a list and removes entries with empty values. ``` $list := list 1 "a" "foo" "" @@ -958,10 +1161,12 @@ $copy := compact $list `compact` panics if there is a problem and `mustCompact` returns an error to the template engine if there is a problem. +
slice, mustSliceTo get partial elements of a list, use `slice list [n] [m]`. It is equivalent of `list[n:m]`. - `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. @@ -971,23 +1176,29 @@ equivalent of `list[n:m]`. `slice` panics if there is a problem while `mustSlice` returns an error to the template engine if there is a problem. +
chunkTo split a list into chunks of given size, use `chunk size list`. This is useful for pagination. ``` chunk 3 (list 1 2 3 4 5 6 7 8) ``` This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. +
-## A Note on List Internals +### A Note on List Internals A list is implemented in Go as a `[]interface{}`. For Go developers embedding Sprig, you may pass `[]interface{}` items into your template context and be able to use all of the `list` functions on those items. -# Dictionaries and Dict Functions + +## Dictionaries and Dict Functions Sprig provides a key/value storage type called a `dict` (short for "dictionary", as in Python). A `dict` is an _unorder_ type. @@ -998,9 +1209,10 @@ type, even another `dict` or `list`. Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will modify the contents of a dictionary. -## dict - -Creating dictionaries is done by calling the `dict` function and passing it a + + + + + -## get - -Given a map and a key, get the value from the map. + + + + -## set - -Use `set` to add a new key/value pair to a dictionary. + + + + -## unset - -Given a map and a key, delete the key from the map. + + + + -## hasKey - -The `hasKey` function returns `true` if the given dict contains the given key. + + + + -## pluck - -The `pluck` function makes it possible to give one key and multiple maps, and + + + + -## dig - -The `dig` function traverses a nested set of dicts, selecting keys from a list + + + + -## keys - -The `keys` function will return a `list` of all of the keys in one or more `dict` + + + + -## pick - -The `pick` function selects just the given keys out of a dictionary, creating a + + + + -## omit - -The `omit` function is similar to `pick`, except it returns a new `dict` with all + + + + -## values - -The `values` function is similar to `keys`, except it returns a new `list` with + + + + +
dictCreating dictionaries is done by calling the `dict` function and passing it a list of pairs. The following creates a dictionary with three items: @@ -1008,10 +1220,12 @@ The following creates a dictionary with three items: ``` $myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" ``` +
getGiven a map and a key, get the value from the map. ``` get $myDict "name1" @@ -1021,10 +1235,12 @@ The above returns `"value1"` Note that if the key is not found, this operation will simply return `""`. No error will be generated. +
setUse `set` to add a new key/value pair to a dictionary. ``` $_ := set $myDict "name4" "value4" @@ -1032,10 +1248,12 @@ $_ := set $myDict "name4" "value4" Note that `set` _returns the dictionary_ (a requirement of Go template functions), so you may need to trap the value as done above with the `$_` assignment. +
unsetGiven a map and a key, delete the key from the map. ``` $_ := unset $myDict "name4" @@ -1045,20 +1263,24 @@ As with `set`, this returns the dictionary. Note that if the key is not found, this operation will simply return. No error will be generated. +
hasKeyThe `hasKey` function returns `true` if the given dict contains the given key. ``` hasKey $myDict "name1" ``` If the key is not found, this returns `false`. +
pluckThe `pluck` function makes it possible to give one key and multiple maps, and get a list of all of the matches: ``` @@ -1076,10 +1298,12 @@ inserted. A common idiom in Sprig templates is to uses `pluck... | first` to get the first matching key out of a collection of dictionaries. +
digThe `dig` function traverses a nested set of dicts, selecting keys from a list of values. It returns a default value if any of the keys are not found at the associated dict. @@ -1107,10 +1331,12 @@ especially since Go's template package's `and` doesn't shortcut. For instance `a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) `dig` accepts its dict argument last in order to support pipelining. +
keysThe `keys` function will return a `list` of all of the keys in one or more `dict` types. Since a dictionary is _unordered_, the keys will not be in a predictable order. They can be sorted with `sortAlpha`. @@ -1124,10 +1350,12 @@ function along with `sortAlpha` to get a unqiue, sorted list of keys. ``` keys $myDict $myOtherDict | uniq | sortAlpha ``` +
pickThe `pick` function selects just the given keys out of a dictionary, creating a new `dict`. ``` @@ -1135,10 +1363,12 @@ $new := pick $myDict "name1" "name2" ``` The above returns `{name1: value1, name2: value2}` +
omitThe `omit` function is similar to `pick`, except it returns a new `dict` with all the keys that _do not_ match the given keys. ``` @@ -1146,10 +1376,12 @@ $new := omit $myDict "name1" "name3" ``` The above returns `{name2: value2}` +
valuesThe `values` function is similar to `keys`, except it returns a new `list` with all the values of the source `dict` (only one dictionary is supported). ``` @@ -1159,48 +1391,68 @@ $vals := values $myDict The above returns `list["value1", "value2", "value 3"]`. Note that the `values` function gives no guarantees about the result ordering- if you care about this, then use `sortAlpha`. -# Type Conversion Functions +
+ +## Type Conversion Functions The following type conversion functions are provided by Sprig: -- `atoi`: Convert a string to an integer. -- `float64`: Convert to a `float64`. -- `int`: Convert to an `int` at the system's width. -- `int64`: Convert to an `int64`. -- `toDecimal`: Convert a unix octal to a `int64`. -- `toString`: Convert to a string. -- `toStrings`: Convert a list, slice, or array to a list of strings. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
atoiConvert a string to an integer. +
float64Convert to a `float64`. +
intConvert to an `int` at the system's width. +
int64Convert to an `int64`. +
toDecimalConvert a unix octal to a `int64`. +
toStringConvert to a string. +
toStringsConvert a list, slice, or array to a list of strings. +
Only `atoi` requires that the input be a specific type. The others will attempt to convert from any type to the destination type. For example, `int64` can convert floats to ints, and it can also convert strings to ints. -## toStrings - -Given a list-like collection, produce a slice of strings. - -``` -list 1 2 3 | toStrings -``` - -The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns -them as a list. - -## toDecimal - -Given a unix octal permission, produce a decimal. - -``` -"0777" | toDecimal -``` - -The above converts `0777` to `511` and returns the value as an int64. -# Path and Filepath Functions +## Path and Filepath Functions While Sprig does not grant access to the filesystem, it does provide functions for working with strings that follow file path conventions. -## Paths +### Paths Paths separated by the slash character (`/`), processed by the `path` package. @@ -1214,46 +1466,58 @@ Examples: [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): `https://example.com/some/content/`, `ftp://example.com/file/`. -### base - -Return the last element of a path. + + + + + -### dir - -Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` + + + + -### clean - -Clean up a path. + + + + -### ext - -Return the file extension. + + + + -### isAbs + + + + +
baseReturn the last element of a path. ``` base "foo/bar/baz" ``` The above prints "baz". +
dirReturn the directory, stripping the last part of the path. So `dir "foo/bar/baz"` returns `foo/bar`. +
cleanClean up a path. ``` clean "foo/bar/../baz" ``` The above resolves the `..` and returns `foo/baz`. +
extReturn the file extension. ``` ext "foo.bar" ``` The above returns `.bar`. +
isAbsTo check whether a path is absolute, use `isAbs`. +
-To check whether a path is absolute, use `isAbs`. - -## Filepaths +### Filepaths Paths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package. @@ -1267,9 +1531,10 @@ Examples: the filesystem path is separated by the backslash character (`\`): `C:\Users\Username\`, `C:\Program Files\Application\`; -### osBase - -Return the last element of a filepath. + + + + + -### osDir - -Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` + + + + -### osClean - -Clean up a path. + + + + -### osExt - -Return the file extension. + + + + -### osIsAbs + + + + +
osBaseReturn the last element of a filepath. ``` osBase "/foo/bar/baz" @@ -1277,16 +1542,20 @@ osBase "C:\\foo\\bar\\baz" ``` The above prints "baz" on Linux and Windows, respectively. +
osDirReturn the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` returns `C:\\foo\\bar` on Windows. +
osCleanClean up a path. ``` osClean "/foo/bar/../baz" @@ -1294,10 +1563,12 @@ osClean "C:\\foo\\bar\\..\\baz" ``` The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. +
osExtReturn the file extension. ``` osExt "/foo.bar" @@ -1305,31 +1576,50 @@ osExt "C:\\foo.bar" ``` The above returns `.bar` on Linux and Windows, respectively. +
osIsAbsTo check whether a file path is absolute, use `osIsAbs`. +
-To check whether a file path is absolute, use `osIsAbs`. -# Flow Control Functions +## Flow Control Functions -## fail - -Unconditionally returns an empty `string` and an `error` with the specified + + + + + +
failUnconditionally returns an empty `string` and an `error` with the specified text. This is useful in scenarios where other conditionals have determined that template rendering should fail. ``` fail "Please accept the end user license agreement" ``` -# UUID Functions +
+ +## UUID Functions Sprig can generate UUID v4 universally unique IDs. + + + + + +
uuidv4 ``` uuidv4 ``` The above returns a new UUID of the v4 (randomly generated) type. -# Reflection Functions +
+ +## Reflection Functions Sprig provides rudimentary reflection tools. These help advanced template developers understand the underlying Go type information for a particular value. @@ -1340,37 +1630,65 @@ Go has an open _type_ system that allows developers to create their own types. Sprig provides a set of functions for each. -## Kind Functions +### Kind Functions -There are two Kind functions: `kindOf` returns the kind of an object. + + + + + + + + + + +
kindOfReturns the kind of an object. ``` kindOf "hello" ``` -The above would return `string`. For simple tests (like in `if` blocks), the -`kindIs` function will let you verify that a value is a particular kind: +The above would return `string`. +
kindIsFor simple tests (like in `if` blocks), the `kindIs` function will let you verify that a value is a particular kind: ``` kindIs "int" 123 ``` The above will return `true` +
-## Type Functions +### Type Functions Types are slightly harder to work with, so there are three different functions: -- `typeOf` returns the underlying type of a value: `typeOf $foo` -- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` -- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. + + + + + + + + + + + + + + + +
typeOfReturns the underlying type of a value: `typeOf $foo` +
typeIsLike `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` +
typeIsLikeWorks as `typeIs`, except that it also dereferences pointers. +
**Note:** None of these can test whether or not something implements a given interface, since doing so would require compiling the interface in ahead of time. -## deepEqual - -`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) + + + + + +
deepEqualReturns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) Works for non-primitive types as well (compared to the built-in `eq`). @@ -1379,21 +1697,28 @@ deepEqual (list 1 2 3) (list 1 2 3) ``` The above will return `true` -# Cryptographic and Security Functions +
+ +## Cryptographic and Security Functions Sprig provides a couple of advanced cryptographic functions. -## sha1sum - -The `sha1sum` function receives a string, and computes it's SHA1 digest. + + + + + -## sha256sum - -The `sha256sum` function receives a string, and computes it's SHA256 digest. + + + + -## sha512sum - -The `sha512sum` function receives a string, and computes it's SHA512 digest. + + + + -## adler32sum - -The `adler32sum` function receives a string, and computes its Adler-32 checksum. + + + + +
sha1sumThe `sha1sum` function receives a string, and computes it's SHA1 digest. ``` sha1sum "Hello world!" ``` +
sha256sumThe `sha256sum` function receives a string, and computes it's SHA256 digest. ``` sha256sum "Hello world!" @@ -1401,10 +1726,12 @@ sha256sum "Hello world!" The above will compute the SHA 256 sum in an "ASCII armored" format that is safe to print. +
sha512sumThe `sha512sum` function receives a string, and computes it's SHA512 digest. ``` sha512sum "Hello world!" @@ -1412,18 +1739,26 @@ sha512sum "Hello world!" The above will compute the SHA 512 sum in an "ASCII armored" format that is safe to print. +
adler32sumThe `adler32sum` function receives a string, and computes its Adler-32 checksum. ``` adler32sum "Hello world!" ``` -# URL Functions +
-## urlParse -Parses string for URL and produces dict with URL parts +## URL Functions + + + + + + -## urlJoin -Joins map (produced by `urlParse`) to produce URL string + + + + +
urlParseParses string for URL and produces dict with URL parts ``` urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" @@ -1441,9 +1776,12 @@ userinfo: 'admin:secret' ``` For more info, check https://golang.org/pkg/net/url/#URL +
urlJoinJoins map (produced by `urlParse`) to produce URL string ``` urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") @@ -1453,3 +1791,6 @@ The above returns the following string: ``` proto://host:80/path?query#fragment ``` +
diff --git a/server/server.go b/server/server.go index c6991ba8..7bad3fde 100644 --- a/server/server.go +++ b/server/server.go @@ -1183,7 +1183,7 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) { if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON } - t, err := template.New("").Funcs(sprig.FuncMap()).Parse(tpl) + t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl) if err != nil { return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } @@ -2111,32 +2111,3 @@ func (s *Server) updateAndWriteStats(messagesCount int64) { } }() } - -func loadTemplatesFromDir(dir string) (map[string]*template.Template, error) { - templates := make(map[string]*template.Template) - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasSuffix(name, ".tmpl") { - continue - } - path := filepath.Join(dir, name) - content, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read template %s: %w", name, err) - } - tmpl, err := template.New(name).Funcs(sprig.FuncMap()).Parse(string(content)) - if err != nil { - return nil, fmt.Errorf("failed to parse template %s: %w", name, err) - } - base := strings.TrimSuffix(name, ".tmpl") - templates[base] = tmpl - } - return templates, nil -} diff --git a/server/templates/github.yml b/server/templates/github.yml index 92f3ab13..5d1b0b46 100644 --- a/server/templates/github.yml +++ b/server/templates/github.yml @@ -1,31 +1,56 @@ title: | - {{- if .pull_request }} - Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }} - {{- else if and .starred_at (eq .action "created")}} - ⭐ {{ .sender.login }} starred {{ .repository.full_name }} + {{- if and .starred_at (eq .action "created")}} + ⭐ {{ .sender.login }} starred {{ .repository.name }} + + {{- else if and .repository (eq .action "started")}} + 👀 {{ .sender.login }} started watching {{ .repository.name }} + {{- else if and .comment (eq .action "created") }} - 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} + 💬 New comment on #{{ .issue.number }}: {{ .issue.title }} + + {{- else if .pull_request }} + 🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }} + + {{- else if .issue }} + 🐛 Issue {{ .action }}: #{{ .issue.number }} {{ .issue.title }} + {{- else }} - Unsupported GitHub event type or action. + {{ fail "Unsupported GitHub event type or action." }} {{- end }} message: | - {{- if .pull_request }} - Repository: {{ .repository.full_name }}, branch {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} - Created by: {{ .pull_request.user.login }} - Link: {{ .pull_request.html_url }} - {{ if .pull_request.body }}Description: - {{ .pull_request.body }}{{ end }} - {{- else if and .starred_at (eq .action "created")}} - ⭐ {{ .sender.login }} starred {{ .repository.full_name }} - 📦 {{ .repository.description | default "(no description)" }} - 🔗 {{ .repository.html_url }} - 📅 {{ .starred_at }} + {{ if and .starred_at (eq .action "created")}} + Stargazer: {{ .sender.html_url }} + Repository: {{ .repository.html_url }} + + {{- else if and .repository (eq .action "started")}} + Watcher: {{ .sender.html_url }} + Repository: {{ .repository.html_url }} + {{- else if and .comment (eq .action "created") }} - 💬 New comment on issue #{{ .issue.number }} — {{ .issue.title }} - 📦 {{ .repository.full_name }} - 👤 {{ .comment.user.login }} - 🔗 {{ .comment.html_url }} - 📝 {{ .comment.body | default "(no comment body)" }} + Commenter: {{ .comment.user.html_url }} + Repository: {{ .repository.html_url }} + Comment link: {{ .comment.html_url }} + {{ if .comment.body }} + Comment: + {{ .comment.body | trunc 2000 }}{{ end }} + + {{- else if .pull_request }} + Branch: {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }} + {{ .action | title }} by: {{ .pull_request.user.html_url }} + Repository: {{ .repository.html_url }} + Pull request: {{ .pull_request.html_url }} + {{ if .pull_request.body }} + Description: + {{ .pull_request.body | trunc 2000 }}{{ end }} + + {{- else if .issue }} + {{ .action | title }} by: {{ .issue.user.html_url }} + Repository: {{ .repository.html_url }} + Issue link: {{ .issue.html_url }} + {{ if .issue.body }} + Description: + {{ .issue.body | trunc 2000 }}{{ end }} + {{- else }} {{ fail "Unsupported GitHub event type or action." }} {{- end }} diff --git a/server/testdata/webhook_github_issue_opened.json b/server/testdata/webhook_github_issue_opened.json new file mode 100644 index 00000000..1b3e74c0 --- /dev/null +++ b/server/testdata/webhook_github_issue_opened.json @@ -0,0 +1,216 @@ +{ + "action": "opened", + "issue": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391", + "repository_url": "https://api.github.com/repos/binwiederhier/ntfy", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/labels{/name}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/comments", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/events", + "html_url": "https://github.com/binwiederhier/ntfy/issues/1391", + "id": 3236389051, + "node_id": "I_kwDOGRBhi87A52C7", + "number": 1391, + "title": "http 500 error (ntfy error 50001)", + "user": { + "login": "TheUser-dev", + "id": 213207407, + "node_id": "U_kgDODLVJbw", + "avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheUser-dev", + "html_url": "https://github.com/TheUser-dev", + "followers_url": "https://api.github.com/users/TheUser-dev/followers", + "following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}", + "gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions", + "organizations_url": "https://api.github.com/users/TheUser-dev/orgs", + "repos_url": "https://api.github.com/users/TheUser-dev/repos", + "events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheUser-dev/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 3480884102, + "node_id": "LA_kwDOGRBhi87PehOG", + "url": "https://api.github.com/repos/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug", + "name": "🪲 bug", + "color": "d73a4a", + "default": false, + "description": "Something isn't working" + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + ], + "milestone": null, + "comments": 0, + "created_at": "2025-07-16T15:20:56Z", + "updated_at": "2025-07-16T15:20:56Z", + "closed_at": null, + "author_association": "NONE", + "active_lock_reason": null, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "body": ":lady_beetle: **Describe the bug**\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\n\n:computer: **Components impacted**\nntfy server 2.13.0 in docker, debian 12 arm64\n\n:bulb: **Screenshots and/or logs**\n```\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:, visitor_ip=, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n```\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?\n", + "reactions": { + "url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "repository": { + "id": 420503947, + "node_id": "R_kgDOGRBhiw", + "name": "ntfy", + "full_name": "binwiederhier/ntfy", + "private": false, + "owner": { + "login": "binwiederhier", + "id": 664597, + "node_id": "MDQ6VXNlcjY2NDU5Nw==", + "avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/binwiederhier", + "html_url": "https://github.com/binwiederhier", + "followers_url": "https://api.github.com/users/binwiederhier/followers", + "following_url": "https://api.github.com/users/binwiederhier/following{/other_user}", + "gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}", + "starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions", + "organizations_url": "https://api.github.com/users/binwiederhier/orgs", + "repos_url": "https://api.github.com/users/binwiederhier/repos", + "events_url": "https://api.github.com/users/binwiederhier/events{/privacy}", + "received_events_url": "https://api.github.com/users/binwiederhier/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "html_url": "https://github.com/binwiederhier/ntfy", + "description": "Send push notifications to your phone or desktop using PUT/POST", + "fork": false, + "url": "https://api.github.com/repos/binwiederhier/ntfy", + "forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks", + "keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams", + "hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks", + "issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}", + "events_url": "https://api.github.com/repos/binwiederhier/ntfy/events", + "assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}", + "branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}", + "tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags", + "blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}", + "languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages", + "stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers", + "contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors", + "subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers", + "subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription", + "commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}", + "compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges", + "archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads", + "issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}", + "pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}", + "milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}", + "notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}", + "releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}", + "deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments", + "created_at": "2021-10-23T19:25:32Z", + "updated_at": "2025-07-16T14:54:16Z", + "pushed_at": "2025-07-16T11:49:26Z", + "git_url": "git://github.com/binwiederhier/ntfy.git", + "ssh_url": "git@github.com:binwiederhier/ntfy.git", + "clone_url": "https://github.com/binwiederhier/ntfy.git", + "svn_url": "https://github.com/binwiederhier/ntfy", + "homepage": "https://ntfy.sh", + "size": 36831, + "stargazers_count": 25112, + "watchers_count": 25112, + "language": "Go", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 984, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 369, + "license": { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZTI=" + }, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + "curl", + "notifications", + "ntfy", + "ntfysh", + "pubsub", + "push-notifications", + "rest-api" + ], + "visibility": "public", + "forks": 984, + "open_issues": 369, + "watchers": 25112, + "default_branch": "main" + }, + "sender": { + "login": "TheUser-dev", + "id": 213207407, + "node_id": "U_kgDODLVJbw", + "avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/TheUser-dev", + "html_url": "https://github.com/TheUser-dev", + "followers_url": "https://api.github.com/users/TheUser-dev/followers", + "following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}", + "gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}", + "starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions", + "organizations_url": "https://api.github.com/users/TheUser-dev/orgs", + "repos_url": "https://api.github.com/users/TheUser-dev/repos", + "events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}", + "received_events_url": "https://api.github.com/users/TheUser-dev/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + } +} diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 68ef516d..1cd026c6 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -2,40 +2,26 @@ package sprig import ( "errors" - "html/template" + "golang.org/x/text/cases" + "golang.org/x/text/language" "math/rand" "path" "path/filepath" "reflect" "strconv" "strings" - ttemplate "text/template" + "text/template" "time" - - "golang.org/x/text/cases" ) -// FuncMap produces the function map. +// TxtFuncMap produces the function map. // // Use this to pass the functions into the template engine: // // tpl := template.New("foo").Funcs(sprig.FuncMap())) -func FuncMap() template.FuncMap { - return HTMLFuncMap() -} - +// // TxtFuncMap returns a 'text/template'.FuncMap -func TxtFuncMap() ttemplate.FuncMap { - return GenericFuncMap() -} - -// HTMLFuncMap returns an 'html/template'.Funcmap -func HTMLFuncMap() template.FuncMap { - return GenericFuncMap() -} - -// GenericFuncMap returns a copy of the basic function map as a map[string]any. -func GenericFuncMap() map[string]any { +func TxtFuncMap() template.FuncMap { gfm := make(map[string]any, len(genericMap)) for k, v := range genericMap { gfm[k] = v @@ -63,11 +49,13 @@ var genericMap = map[string]any{ "unixEpoch": unixEpoch, // Strings - "trunc": trunc, - "trim": strings.TrimSpace, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "title": cases.Title, + "trunc": trunc, + "trim": strings.TrimSpace, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": func(s string) string { + return cases.Title(language.English).String(s) + }, "substr": substring, // Switch order so that "foo" | repeat 5 "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, @@ -99,11 +87,6 @@ var genericMap = map[string]any{ "seq": seq, "toDecimal": toDecimal, - //"gt": func(a, b int) bool {return a > b}, - //"gte": func(a, b int) bool {return a >= b}, - //"lt": func(a, b int) bool {return a < b}, - //"lte": func(a, b int) bool {return a <= b}, - // split "/" foo/bar returns map[int]string{0: foo, 1: bar} "split": split, "splitList": func(sep, orig string) []string { return strings.Split(orig, sep) }, From ae62e0d9556381bdc835f803fa55476c381975f1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 15:37:05 +0200 Subject: [PATCH 45/87] Docs docs docs --- client/options.go | 6 + cmd/publish.go | 5 + cmd/serve.go | 8 +- cmd/user.go | 3 +- docs/publish.md | 196 +++- docs/{ => publish}/template-functions.md | 1041 ++++++----------- docs/sprig.md | 24 - docs/sprig/conversion.md | 36 - docs/sprig/crypto.md | 41 - docs/sprig/date.md | 126 -- docs/sprig/defaults.md | 169 --- docs/sprig/dicts.md | 172 --- docs/sprig/encoding.md | 6 - docs/sprig/flow_control.md | 11 - docs/sprig/integer_slice.md | 41 - docs/sprig/lists.md | 188 --- docs/sprig/math.md | 78 -- docs/sprig/paths.md | 114 -- docs/sprig/reflection.md | 50 - docs/sprig/string_slice.md | 72 -- docs/sprig/strings.md | 309 ----- docs/sprig/url.md | 33 - docs/sprig/uuid.md | 9 - .../android-screenshot-template-custom.png | Bin 0 -> 45032 bytes ...android-screenshot-template-predefined.png | Bin 0 -> 86828 bytes .../img/screenshot-github-webhook-config.png | Bin 0 -> 98734 bytes mkdocs.yml | 1 - server/config.go | 6 +- server/server.yml | 20 + server/server_test.go | 75 +- server/templates/alertmanager.yml | 29 + server/templates/github.yml | 3 +- server/templates/grafana.yml | 16 +- .../testdata/webhook_alertmanager_firing.json | 33 + server/testdata/webhook_grafana_resolved.json | 51 + 35 files changed, 764 insertions(+), 2208 deletions(-) rename docs/{ => publish}/template-functions.md (59%) delete mode 100644 docs/sprig.md delete mode 100644 docs/sprig/conversion.md delete mode 100644 docs/sprig/crypto.md delete mode 100644 docs/sprig/date.md delete mode 100644 docs/sprig/defaults.md delete mode 100644 docs/sprig/dicts.md delete mode 100644 docs/sprig/encoding.md delete mode 100644 docs/sprig/flow_control.md delete mode 100644 docs/sprig/integer_slice.md delete mode 100644 docs/sprig/lists.md delete mode 100644 docs/sprig/math.md delete mode 100644 docs/sprig/paths.md delete mode 100644 docs/sprig/reflection.md delete mode 100644 docs/sprig/string_slice.md delete mode 100644 docs/sprig/strings.md delete mode 100644 docs/sprig/url.md delete mode 100644 docs/sprig/uuid.md create mode 100644 docs/static/img/android-screenshot-template-custom.png create mode 100644 docs/static/img/android-screenshot-template-predefined.png create mode 100644 docs/static/img/screenshot-github-webhook-config.png create mode 100644 server/templates/alertmanager.yml create mode 100644 server/testdata/webhook_alertmanager_firing.json create mode 100644 server/testdata/webhook_grafana_resolved.json diff --git a/client/options.go b/client/options.go index 027b7fb5..f4711834 100644 --- a/client/options.go +++ b/client/options.go @@ -77,6 +77,12 @@ func WithMarkdown() PublishOption { return WithHeader("X-Markdown", "yes") } +// WithTemplate instructs the server to use a specific template for the message. If templateName is is "yes" or "1", +// the server will interpret the message and title as a template. +func WithTemplate(templateName string) PublishOption { + return WithHeader("X-Template", templateName) +} + // WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment func WithFilename(filename string) PublishOption { return WithHeader("X-Filename", filename) diff --git a/cmd/publish.go b/cmd/publish.go index c15761ab..f3139a63 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -32,6 +32,7 @@ var flagsPublish = append( &cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"}, &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"}, &cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"}, + &cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"}, &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"}, &cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"}, @@ -98,6 +99,7 @@ func execPublish(c *cli.Context) error { actions := c.String("actions") attach := c.String("attach") markdown := c.Bool("markdown") + template := c.String("template") filename := c.String("filename") file := c.String("file") email := c.String("email") @@ -146,6 +148,9 @@ func execPublish(c *cli.Context) error { if markdown { options = append(options, client.WithMarkdown()) } + if template != "" { + options = append(options, client.WithTemplate(template)) + } if filename != "" { options = append(options, client.WithFilename(filename)) } diff --git a/cmd/serve.go b/cmd/serve.go index 0cbade0f..f894fe65 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -29,13 +29,9 @@ func init() { commands = append(commands, cmdServe) } -const ( - defaultServerConfigFile = "/etc/ntfy/server.yml" -) - var flagsServe = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}), @@ -56,7 +52,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Usage: "directory to load named message templates from"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Value: server.DefaultTemplateDir, Usage: "directory to load named message templates from"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}), diff --git a/cmd/user.go b/cmd/user.go index e6867b11..0ee45bc3 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -6,6 +6,7 @@ import ( "crypto/subtle" "errors" "fmt" + "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "os" "strings" @@ -25,7 +26,7 @@ func init() { var flagsUser = append( append([]cli.Flag{}, flagsDefault...), - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"}, + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"}, altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), ) diff --git a/docs/publish.md b/docs/publish.md index 24aa443a..6410bece 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -944,27 +944,165 @@ Templating lets you **format a JSON message body into human-friendly message and [Go templates](https://pkg.go.dev/text/template) (see tutorials [here](https://blog.gopheracademy.com/advent-2017/using-go-templates/), [here](https://www.digitalocean.com/community/tutorials/how-to-use-templates-in-go), and [here](https://developer.hashicorp.com/nomad/tutorials/templates/go-template-syntax)). This is specifically useful when -**combined with webhooks** from services such as GitHub, Grafana, or other services that emit JSON webhooks. +**combined with webhooks** from services such as [GitHub](https://docs.github.com/en/webhooks/about-webhooks), +[Grafana](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/), +[Alertmanager](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config), or other services that emit JSON webhooks. Instead of using a separate bridge program to parse the webhook body into the format ntfy expects, you can include a templated message and/or a templated title which will be populated based on the fields of the webhook body (so long as the webhook body is valid JSON). -You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`): +You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): +* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a template name (e.g. `?template=github`) + to a pre-defined template name (e.g. `github`, `grafana`, or `alertmanager`) will use the template with that name. + See [pre-defined templates](#pre-defined-templates) for more details. +* **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`) + will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). + See [custom templates](#custom-templates) for more details. * **Inline templating**: Setting the `X-Template` header or query parameter to `yes` or `1` (e.g. `?template=yes`) - will enable inline templating, which means that the `message` and/or `title` **will be parsed as a Go template**. - See [Inline templating](#inline-templating) and [Template syntax](#template-syntax) for details on how to use Go - templates in your messages and titles. -* **Pre-defined template files**: You can also set `X-Template` header or query parameter to a template name (e.g. `?template=github`). - ntfy will then read the template from either the built-in pre-defined template files, or from the template files defined in - the `template-dir`. See [Template files](#pre-defined-templates) for more details. + will enable inline templating, which means that the `message` and/or `title` will be parsed as a Go template. + See [inline templating](#inline-templating) for more details. + +To learn the basics of Go's templating language, please see [template syntax](#template-syntax). + +### Pre-defined templates + +When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the +message and/or title based on one of the built-in pre-defined templates + +The following **pre-defined templates** are available: + +* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment) +* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts) +* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts) + +Here's an example of how to use the pre-defined `github` template: First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. +
+ ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 } +
GitHub webhook configuration
+
+ +After that, when GitHub publishes a JSON webhook to the topic, ntfy will transform it according to the template rules +and you'll receive notifications in the ntfy app. Here's an example for when somebody stars your repository: + +
+ ![pre-defined template](static/img/android-screenshot-template-predefined.png){ width=500 } +
Receiving a webhook, formatted using the pre-defined "github" template
+
+ +### Custom templates + +To define **your own custom templates**, place a template file in the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`) +and set the `X-Template` header or query parameter to the name of the template file (without the `.yml` extension). + +For example, if you have a template file `/etc/ntfy/templates/myapp.yml`, you can set the header `X-Template: myapp` or +the query parameter `?template=myapp` to use it. + +Template files must have the `.yml` (not: `.yaml`!) extension and must be formatted as YAML. They may contain `title` and `message` keys, +which are interpreted as Go templates. + +Here's an **example custom template**: + +=== "Custom template (/etc/ntfy/templates/myapp.yml)" + ```yaml + title: | + {{- if eq .status "firing" }} + {{- if gt .percent 90.0 }}🚨 Critical alert + {{- else }}⚠️ Alert{{- end }} + {{- else if eq .status "resolved" }} + ✅ Alert resolved + {{- end }} + message: | + Status: {{ .status }} + Type: {{ .type | upper }} ({{ .percent }}%) + Server: {{ .server }} + ``` + +Once you have the template file in place, you can send the payload to your topic using the `X-Template` +header or query parameter: + +=== "Command line (curl)" + ``` + echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ + curl -sT- "https://ntfy.example.com/mytopic?template=myapp" + ``` + +=== "ntfy CLI" + ``` + echo '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' | \ + ntfy publish --template=myapp https://ntfy.example.com/mytopic + ``` + +=== "HTTP" + ``` http + POST /mytopic?template=myapp HTTP/1.1 + Host: ntfy.example.com + + { + "status": "firing", + "type": "cpu", + "server": "ntfy.sh", + "percent": 99 + } + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.example.com/mytopic?template=myapp', { + method: 'POST', + body: '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + }) + ``` + +=== "Go" + ``` go + payload := `{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}` + req, _ := http.NewRequest("POST", "https://ntfy.example.com/mytopic?template=myapp", strings.NewReader(payload)) + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + Uri = "https://ntfy.example.com/mytopic?template=myapp" + Body = '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.example.com/mytopic?template=myapp", + json={"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.example.com/mytopic?template=myapp', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json", + 'content' => '{"status":"firing","type":"cpu","server":"ntfy.sh","percent":99}' + ] + ])); + ``` + +Which will result in a notification that looks like this: + +
+ ![notification from custom JSON webhook template](static/img/android-screenshot-template-custom.png){ width=500 } +
JSON webhook, transformed using a custom template
+
### Inline templating -When `X-Template: yes` or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your -webhook payload. This is most useful if no [pre-defined template](#pre-defined-templates) exists, for templated one-off messages, -of if you do not control the ntfy server (e.g., if you're using ntfy.sh). Please consider using [template files](#pre-defined-templates) +When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your +webhook payload. + +Inline templates are most useful for templated one-off messages, of if you do not control the ntfy server (e.g., if you're using ntfy.sh). +Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead, if you control the ntfy server, as templates are much easier to maintain. Here's an **example for a Grafana alert**: @@ -1078,10 +1216,6 @@ This example uses the `message`/`m` and `title`/`t` query parameters, but obviou `Message`/`Title` headers. It will send a notification with a title `phil-pc: A severe error has occurred` and a message `Error message: Disk has run out of space`. -### Pre-defined templates - -XXXXXXXXXXXXxx - ### Template syntax ntfy uses [Go templates](https://pkg.go.dev/text/template) for its templates, which is arguably one of the most powerful, yet also one of the worst templating languages out there. @@ -1101,23 +1235,23 @@ message templating and for transforming the data provided through the JSON paylo Below are the functions that are available to use inside your message/title templates. -* [String Functions](./sprig/strings.md): `trim`, `trunc`, `substr`, `plural`, etc. - * [String List Functions](./sprig/string_slice.md): `splitList`, `sortAlpha`, etc. -* [Integer Math Functions](./sprig/math.md): `add`, `max`, `mul`, etc. - * [Integer List Functions](./sprig/integer_slice.md): `until`, `untilStep` -* [Date Functions](./sprig/date.md): `now`, `date`, etc. -* [Defaults Functions](./sprig/defaults.md): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` -* [Encoding Functions](./sprig/encoding.md): `b64enc`, `b64dec`, etc. -* [Lists and List Functions](./sprig/lists.md): `list`, `first`, `uniq`, etc. -* [Dictionaries and Dict Functions](./sprig/dicts.md): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. -* [Type Conversion Functions](./sprig/conversion.md): `atoi`, `int64`, `toString`, etc. -* [Path and Filepath Functions](./sprig/paths.md): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` -* [Flow Control Functions]( ./sprig/flow_control.md): `fail` +* [String Functions](publish/template-functions.md#string-functions): `trim`, `trunc`, `substr`, `plural`, etc. +* [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. +* [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc. +* [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep` +* [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc. +* [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` +* [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc. +* [Lists and List Functions](publish/template-functions.md#lists-and-list-functions): `list`, `first`, `uniq`, etc. +* [Dictionaries and Dict Functions](publish/template-functions.md#dictionaries-and-dict-functions): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. +* [Type Conversion Functions](publish/template-functions.md#type-conversion-functions): `atoi`, `int64`, `toString`, etc. +* [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` +* [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail` * Advanced Functions - * [UUID Functions](./sprig/uuid.md): `uuidv4` - * [Reflection](./sprig/reflection.md): `typeOf`, `kindIs`, `typeIsLike`, etc. - * [Cryptographic and Security Functions](./sprig/crypto.md): `sha256sum`, etc. - * [URL](./sprig/url.md): `urlParse`, `urlJoin` + * [UUID Functions](publish/template-functions.md#uuid-functions): `uuidv4` + * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc. + * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc. + * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin` ## Publish as JSON diff --git a/docs/template-functions.md b/docs/publish/template-functions.md similarity index 59% rename from docs/template-functions.md rename to docs/publish/template-functions.md index 7c9593e6..238bddd9 100644 --- a/docs/template-functions.md +++ b/docs/publish/template-functions.md @@ -1,4 +1,8 @@ -# Template functions +# Template Functions + +These template functions may be used in the [message template](../publish.md#message-templating) feature of ntfy. Please refer to the examples in the documentation for how to use them. + +The original set of template functions is based on the [Sprig library](https://masterminds.github.io/sprig/). This documentation page is a (slightly modified) copy of their docs. **Thank you to the Sprig developers for their work!** 🙏 ## Table of Contents @@ -23,106 +27,89 @@ Sprig has a number of string manipulation functions. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
trimThe `trim` function removes space from either side of a string: +### trim + +The `trim` function removes space from either side of a string: ``` trim " hello " ``` The above produces `hello` -
trimAllRemove given characters from the front or back of a string: +### trimAll + +Remove given characters from the front or back of a string: ``` trimAll "$" "$5.00" ``` The above returns `5.00` (as a string). -
trimSuffixTrim just the suffix from a string: +### trimSuffix + +Trim just the suffix from a string: ``` trimSuffix "-" "hello-" ``` The above returns `hello` -
trimPrefixTrim just the prefix from a string: +### trimPrefix + +Trim just the prefix from a string: ``` trimPrefix "-" "-hello" ``` The above returns `hello` -
upperConvert the entire string to uppercase: +### upper + +Convert the entire string to uppercase: ``` upper "hello" ``` The above returns `HELLO` -
lowerConvert the entire string to lowercase: +### lower + +Convert the entire string to lowercase: ``` lower "HELLO" ``` The above returns `hello` -
titleConvert to title case: +### title + +Convert to title case: ``` title "hello world" ``` The above returns `Hello World` -
repeatRepeat a string multiple times: +### repeat + +Repeat a string multiple times: ``` repeat 3 "hello" ``` The above returns `hellohellohello` -
substrGet a substring from a string. It takes three parameters: +### substr + +Get a substring from a string. It takes three parameters: - start (int) - end (int) @@ -133,12 +120,10 @@ substr 0 5 "hello world" ``` The above returns `hello` -
truncTruncate a string (and add no suffix) +### trunc + +Truncate a string (and add no suffix) ``` trunc 5 "hello world" @@ -151,24 +136,20 @@ trunc -5 "hello world" ``` The above produces `world`. -
containsTest to see if one string is contained inside of another: +### contains + +Test to see if one string is contained inside of another: ``` contains "cat" "catch" ``` The above returns `true` because `catch` contains `cat`. -
hasPrefix and hasSuffixThe `hasPrefix` and `hasSuffix` functions test whether a string has a given +### hasPrefix and hasSuffix + +The `hasPrefix` and `hasSuffix` functions test whether a string has a given prefix or suffix: ``` @@ -176,19 +157,15 @@ hasPrefix "cat" "catch" ``` The above returns `true` because `catch` has the prefix `cat`. -
quote and squoteThese functions wrap a string in double quotes (`quote`) or single quotes +### quote and squote + +These functions wrap a string in double quotes (`quote`) or single quotes (`squote`). -
catThe `cat` function concatenates multiple strings together into one, separating +### cat + +The `cat` function concatenates multiple strings together into one, separating them with spaces: ``` @@ -196,12 +173,10 @@ cat "hello" "beautiful" "world" ``` The above produces `hello beautiful world` -
indentThe `indent` function indents every line in a given string to the specified +### indent + +The `indent` function indents every line in a given string to the specified indent width. This is useful when aligning multi-line strings: ``` @@ -209,12 +184,10 @@ indent 4 $lots_of_text ``` The above will indent every line of text by 4 space characters. -
nindentThe `nindent` function is the same as the indent function, but prepends a new +### nindent + +The `nindent` function is the same as the indent function, but prepends a new line to the beginning of the string. ``` @@ -223,12 +196,10 @@ nindent 4 $lots_of_text The above will indent every line of text by 4 space characters and add a new line to the beginning. -
replacePerform simple string replacement. +### replace + +Perform simple string replacement. It takes three arguments: @@ -241,12 +212,10 @@ It takes three arguments: ``` The above will produce `I-Am-Henry-VIII` -
pluralPluralize a string. +### plural + +Pluralize a string. ``` len $fish | plural "one anchovy" "many anchovies" @@ -266,12 +235,10 @@ NOTE: Sprig does not currently support languages with more complex pluralization rules. And `0` is considered a plural because the English language treats it as such (`zero anchovies`). The Sprig developers are working on a solution for better internationalization. -
regexMatch, mustRegexMatchReturns true if the input string contains any match of the regular expression. +### regexMatch, mustRegexMatch + +Returns true if the input string contains any match of the regular expression. ``` regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" @@ -281,12 +248,10 @@ The above produces `true` `regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the template engine if there is a problem. -
regexFindAll, mustRegexFindAllReturns a slice of all matches of the regular expression in the input string. +### regexFindAll, mustRegexFindAll + +Returns a slice of all matches of the regular expression in the input string. The last parameter n determines the number of substrings to return, where -1 means return all matches ``` @@ -297,12 +262,10 @@ The above produces `[2 4 6 8]` `regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the template engine if there is a problem. -
regexFind, mustRegexFindReturn the first (left most) match of the regular expression in the input string +### regexFind, mustRegexFind + +Return the first (left most) match of the regular expression in the input string ``` regexFind "[a-zA-Z][1-9]" "abcd1234" @@ -312,12 +275,10 @@ The above produces `d1` `regexFind` panics if there is a problem and `mustRegexFind` returns an error to the template engine if there is a problem. -
regexReplaceAll, mustRegexReplaceAllReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. +### regexReplaceAll, mustRegexReplaceAll + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch ``` @@ -328,12 +289,10 @@ The above produces `-W-xxW-` `regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the template engine if there is a problem. -
regexReplaceAllLiteral, mustRegexReplaceAllLiteralReturns a copy of the input string, replacing matches of the Regexp with the replacement string replacement +### regexReplaceAllLiteral, mustRegexReplaceAllLiteral + +Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement The replacement string is substituted directly, without using Expand ``` @@ -344,12 +303,10 @@ The above produces `-${1}-${1}-` `regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the template engine if there is a problem. -
regexSplit, mustRegexSplitSlices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches +### regexSplit, mustRegexSplit + +Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches ``` regexSplit "z+" "pizza" -1 @@ -359,12 +316,10 @@ The above produces `[pi a]` `regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the template engine if there is a problem. -
regexQuoteMetaReturns a string that escapes all regular expression metacharacters inside the argument text; +### regexQuoteMeta + +Returns a string that escapes all regular expression metacharacters inside the argument text; the returned string is a regular expression matching the literal text. ``` @@ -372,22 +327,20 @@ regexQuoteMeta "1.2.3" ``` The above produces `1\.2\.3` -
-The [Conversion Functions](conversion.md) contain functions for converting strings. The [String List Functions](string_slice.md) contains +### See Also... + +The [Conversion Functions](#type-conversion-functions) contain functions for converting strings. The [String List Functions](#string-list-functions) contains functions for working with an array of strings. ## String List Functions -These function operate on or generate slices of strings. In Go, a slice is a +These functions operate on or generate slices of strings. In Go, a slice is a growable array. In Sprig, it's a special case of a `list`. - - - - - - - - - - - - - - - - - -
joinJoin a list of strings into a single string, with the given separator. +### join + +Join a list of strings into a single string, with the given separator. ``` list "hello" "world" | join "_" @@ -402,12 +355,10 @@ list 1 2 3 | join "+" ``` The above will produce `1+2+3` -
splitList and splitSplit a string into a list of strings: +### splitList and split + +Split a string into a list of strings: ``` splitList "$" "foo$bar$baz" @@ -429,12 +380,10 @@ $a._0 ``` The above produces `foo` -
splitn`splitn` function splits a string into a `dict` with `n` keys. It is designed to make +### splitn + +`splitn` function splits a string into a `dict` with `n` keys. It is designed to make it easy to use template dot notation for accessing members: ``` @@ -448,132 +397,99 @@ $a._0 ``` The above produces `foo` -
sortAlphaThe `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) +### sortAlpha + +The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) order. It does _not_ sort in place, but returns a sorted copy of the list, in keeping with the immutability of lists. -
## Integer Math Functions The following math functions operate on `int64` values. - - - - - - - - - +### add1 - - - - +To increment by 1, use `add1` - - - - +### sub - - - - +To subtract, use `sub` - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
addSum numbers with `add`. Accepts two or more inputs. +### add + +Sum numbers with `add`. Accepts two or more inputs. ``` add 1 2 3 ``` -
add1To increment by 1, use `add1` -
subTo subtract, use `sub` -
divPerform integer division with `div` -
modModulo with `mod` -
mulMultiply with `mul`. Accepts two or more inputs. +### div + +Perform integer division with `div` + +### mod + +Modulo with `mod` + +### mul + +Multiply with `mul`. Accepts two or more inputs. ``` mul 1 2 3 ``` -
maxReturn the largest of a series of integers: +### max + +Return the largest of a series of integers: This will return `3`: ``` max 1 2 3 ``` -
minReturn the smallest of a series of integers. +### min + +Return the smallest of a series of integers. `min 1 2 3` will return `1` -
floorReturns the greatest float value less than or equal to input value +### floor + +Returns the greatest float value less than or equal to input value `floor 123.9999` will return `123.0` -
ceilReturns the greatest float value greater than or equal to input value +### ceil + +Returns the greatest float value greater than or equal to input value `ceil 123.001` will return `124.0` -
roundReturns a float value with the remainder rounded to the given number to digits after the decimal point. +### round + +Returns a float value with the remainder rounded to the given number to digits after the decimal point. `round 123.555555 3` will return `123.556` -
randIntReturns a random integer value from min (inclusive) to max (exclusive). +### randInt +Returns a random integer value from min (inclusive) to max (exclusive). ``` randInt 12 30 ``` The above will produce a random number in the range [12,30]. -
## Integer List Functions - - - - - - - - - - - - - -
untilThe `until` function builds a range of integers. +### until + +The `until` function builds a range of integers. ``` until 5 @@ -582,12 +498,10 @@ until 5 The above generates the list `[0, 1, 2, 3, 4]`. This is useful for looping with `range $i, $e := until 5`. -
untilStepLike `until`, `untilStep` generates a list of counting integers. But it allows +### untilStep + +Like `until`, `untilStep` generates a list of counting integers. But it allows you to define a start, stop, and step: ``` @@ -596,12 +510,10 @@ untilStep 3 6 2 The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal or greater than 6. This is similar to Python's `range` function. -
seqWorks like the bash `seq` command. +### seq + +Works like the bash `seq` command. * 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. * 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. * 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. @@ -614,22 +526,16 @@ seq 2 -2 => 2 1 0 -1 -2 seq 0 2 10 => 0 2 4 6 8 10 seq 0 -2 -5 => 0 -2 -4 ``` -
## Date Functions - - - - - +### now - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
nowThe current date/time. Use this in conjunction with other date functions. -
agoThe `ago` function returns duration from time.Now in seconds resolution. +The current date/time. Use this in conjunction with other date functions. + +### ago + +The `ago` function returns duration from time.Now in seconds resolution. ``` ago .CreatedAt @@ -640,12 +546,10 @@ returns in `time.Duration` String() format ``` 2h34m7s ``` -
dateThe `date` function formats a date. +### date + +The `date` function formats a date. Format the date to YEAR-MONTH-DAY: @@ -663,34 +567,28 @@ Mon Jan 2 15:04:05 MST 2006 Write it in the format you want. Above, `2006-01-02` is the same date, but in the format we want. -
dateInZoneSame as `date`, but with a timezone. +### dateInZone + +Same as `date`, but with a timezone. ``` dateInZone "2006-01-02" (now) "UTC" ``` -
durationFormats a given amount of seconds as a `time.Duration`. +### duration + +Formats a given amount of seconds as a `time.Duration`. This returns 1m35s ``` duration "95" ``` -
durationRoundRounds a given duration to the most significant unit. Strings and `time.Duration` +### durationRound + +Rounds a given duration to the most significant unit. Strings and `time.Duration` gets parsed as a duration, while a `time.Time` is calculated as the duration since. This return 2h @@ -704,22 +602,18 @@ This returns 3mo ``` durationRound "2400h10m5s" ``` -
unixEpochReturns the seconds since the unix epoch for a `time.Time`. +### unixEpoch + +Returns the seconds since the unix epoch for a `time.Time`. ``` now | unixEpoch ``` -
dateModify, mustDateModifyThe `dateModify` takes a modification and a date and returns the timestamp. +### dateModify, mustDateModify + +The `dateModify` takes a modification and a date and returns the timestamp. Subtract an hour and thirty minutes from the current time: @@ -728,33 +622,27 @@ now | date_modify "-1.5h" ``` If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. -
htmlDateThe `htmlDate` function formats a date for inserting into an HTML date picker +### htmlDate + +The `htmlDate` function formats a date for inserting into an HTML date picker input field. ``` now | htmlDate ``` -
htmlDateInZoneSame as htmlDate, but with a timezone. +### htmlDateInZone + +Same as htmlDate, but with a timezone. ``` htmlDateInZone (now) "UTC" ``` -
toDate, mustToDate`toDate` converts a string to a date. The first argument is the date layout and +### toDate, mustToDate + +`toDate` converts a string to a date. The first argument is the date layout and the second the date string. If the string can't be convert it returns the zero value. `mustToDate` will return an error in case the string cannot be converted. @@ -765,18 +653,14 @@ This is useful when you want to convert a string date to another format ``` toDate "2006-01-02" "2017-12-31" | date "02/01/2006" ``` -
## Default Functions Sprig provides tools for setting default values for templates. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
defaultTo set a simple default value, use `default`: +### default + +To set a simple default value, use `default`: ``` default "foo" .Bar @@ -796,12 +680,10 @@ The definition of "empty" depends on type: For structs, there is no definition of empty, so a struct will never return the default. -
emptyThe `empty` function returns `true` if the given value is considered empty, and +### empty + +The `empty` function returns `true` if the given value is considered empty, and `false` otherwise. The empty values are listed in the `default` section. ``` @@ -810,12 +692,10 @@ empty .Foo Note that in Go template conditionals, emptiness is calculated for you. Thus, you rarely need `if empty .Foo`. Instead, just use `if .Foo`. -
coalesceThe `coalesce` function takes a list of values and returns the first non-empty +### coalesce + +The `coalesce` function takes a list of values and returns the first non-empty one. ``` @@ -833,12 +713,10 @@ coalesce .name .parent.name "Matt" The above will first check to see if `.name` is empty. If it is not, it will return that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. -
allThe `all` function takes a list of values and returns true if all values are non-empty. +### all + +The `all` function takes a list of values and returns true if all values are non-empty. ``` all 0 1 2 @@ -853,12 +731,10 @@ all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Me ``` The above will check http.Request is POST with tls 1.3 and http/2. -
anyThe `any` function takes a list of values and returns true if any value is non-empty. +### any + +The `any` function takes a list of values and returns true if any value is non-empty. ``` any 0 1 2 @@ -873,23 +749,19 @@ any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method " ``` The above will check http.Request method is one of GET/POST/OPTIONS. -
fromJSON, mustFromJSON`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. +### fromJSON, mustFromJSON + +`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. `mustFromJSON` will return an error in case the JSON is invalid. ``` fromJSON "{\"foo\": 55}" ``` -
toJSON, mustToJSONThe `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. +### toJSON, mustToJSON + +The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. `mustToJSON` will return an error in case the item cannot be encoded in JSON. ``` @@ -897,40 +769,34 @@ toJSON .Item ``` The above returns JSON string representation of `.Item`. -
toPrettyJSON, mustToPrettyJSONThe `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. +### toPrettyJSON, mustToPrettyJSON + +The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. ``` toPrettyJSON .Item ``` The above returns indented JSON string representation of `.Item`. -
toRawJSON, mustToRawJSONThe `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. +### toRawJSON, mustToRawJSON + +The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. ``` toRawJSON .Item ``` The above returns unescaped JSON string representation of `.Item`. -
ternaryThe `ternary` function takes two values, and a test value. If the test value is +### ternary + +The `ternary` function takes two values, and a test value. If the test value is true, the first value will be returned. If the test value is empty, the second value will be returned. This is similar to the c ternary operator. -### true test value +#### true test value ``` ternary "foo" "bar" true @@ -944,7 +810,7 @@ true | ternary "foo" "bar" The above returns `"foo"`. -### false test value +#### false test value ``` ternary "foo" "bar" false @@ -957,27 +823,13 @@ false | ternary "foo" "bar" ``` The above returns `"bar"`. -
## Encoding Functions Sprig has the following encoding and decoding functions: - - - - - - - - - - -
b64enc/b64decEncode or decode with Base64 -
b32enc/b32decEncode or decode with Base32 -
+- `b64enc`/`b64dec`: Encode or decode with Base64 +- `b32enc`/`b32dec`: Encode or decode with Base32 ## Lists and List Functions @@ -993,54 +845,45 @@ $myList := list 1 2 3 4 5 The above creates a list of `[1 2 3 4 5]`. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
first, mustFirstTo get the head item on a list, use `first`. +### first, mustFirst + +To get the head item on a list, use `first`. `first $myList` returns `1` `first` panics if there is a problem while `mustFirst` returns an error to the template engine if there is a problem. -
rest, mustRestTo get the tail of the list (everything but the first item), use `rest`. +### rest, mustRest + +To get the tail of the list (everything but the first item), use `rest`. `rest $myList` returns `[2 3 4 5]` `rest` panics if there is a problem while `mustRest` returns an error to the template engine if there is a problem. -
last, mustLastTo get the last item on a list, use `last`: +### last, mustLast + +To get the last item on a list, use `last`: `last $myList` returns `5`. This is roughly analogous to reversing a list and then calling `first`. `last` panics if there is a problem while `mustLast` returns an error to the template engine if there is a problem. -
initial, mustInitialThis compliments `last` by returning all _but_ the last element. +### initial, mustInitial + +This compliments `last` by returning all _but_ the last element. `initial $myList` returns `[1 2 3 4]`. `initial` panics if there is a problem while `mustInitial` returns an error to the template engine if there is a problem. -
append, mustAppendAppend a new item to an existing list, creating a new list. +### append, mustAppend + +Append a new item to an existing list, creating a new list. ``` $new = append $myList 6 @@ -1050,12 +893,10 @@ The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. `append` panics if there is a problem while `mustAppend` returns an error to the template engine if there is a problem. -
prepend, mustPrependPush an element onto the front of a list, creating a new list. +### prepend, mustPrepend + +Push an element onto the front of a list, creating a new list. ``` prepend $myList 0 @@ -1065,24 +906,20 @@ The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. `prepend` panics if there is a problem while `mustPrepend` returns an error to the template engine if there is a problem. -
concatConcatenate arbitrary number of lists into one. +### concat + +Concatenate arbitrary number of lists into one. ``` concat $myList ( list 6 7 ) ( list 8 ) ``` The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. -
reverse, mustReverseProduce a new list with the reversed elements of the given list. +### reverse, mustReverse + +Produce a new list with the reversed elements of the given list. ``` reverse $myList @@ -1092,12 +929,10 @@ The above would generate the list `[5 4 3 2 1]`. `reverse` panics if there is a problem while `mustReverse` returns an error to the template engine if there is a problem. -
uniq, mustUniqGenerate a list with all of the duplicates removed. +### uniq, mustUniq + +Generate a list with all of the duplicates removed. ``` list 1 1 1 2 | uniq @@ -1107,12 +942,10 @@ The above would produce `[1 2]` `uniq` panics if there is a problem while `mustUniq` returns an error to the template engine if there is a problem. -
without, mustWithoutThe `without` function filters items out of a list. +### without, mustWithout + +The `without` function filters items out of a list. ``` without $myList 3 @@ -1130,12 +963,10 @@ That would produce `[2 4]` `without` panics if there is a problem while `mustWithout` returns an error to the template engine if there is a problem. -
has, mustHasTest to see if a list has a particular element. +### has, mustHas + +Test to see if a list has a particular element. ``` has 4 $myList @@ -1145,12 +976,10 @@ The above would return `true`, while `has "hello" $myList` would return false. `has` panics if there is a problem while `mustHas` returns an error to the template engine if there is a problem. -
compact, mustCompactAccepts a list and removes entries with empty values. +### compact, mustCompact + +Accepts a list and removes entries with empty values. ``` $list := list 1 "a" "foo" "" @@ -1161,12 +990,10 @@ $copy := compact $list `compact` panics if there is a problem and `mustCompact` returns an error to the template engine if there is a problem. -
slice, mustSliceTo get partial elements of a list, use `slice list [n] [m]`. It is +### slice, mustSlice + +To get partial elements of a list, use `slice list [n] [m]`. It is equivalent of `list[n:m]`. - `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. @@ -1176,26 +1003,21 @@ equivalent of `list[n:m]`. `slice` panics if there is a problem while `mustSlice` returns an error to the template engine if there is a problem. -
chunkTo split a list into chunks of given size, use `chunk size list`. This is useful for pagination. +### chunk + +To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. ``` chunk 3 (list 1 2 3 4 5 6 7 8) ``` This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. -
### A Note on List Internals -A list is implemented in Go as a `[]interface{}`. For Go developers embedding -Sprig, you may pass `[]interface{}` items into your template context and be +A list is implemented in Go as a `[]any`. For Go developers embedding +Sprig, you may pass `[]any` items into your template context and be able to use all of the `list` functions on those items. ## Dictionaries and Dict Functions @@ -1209,10 +1031,9 @@ type, even another `dict` or `list`. Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will modify the contents of a dictionary. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
dictCreating dictionaries is done by calling the `dict` function and passing it a +### dict + +Creating dictionaries is done by calling the `dict` function and passing it a list of pairs. The following creates a dictionary with three items: @@ -1220,12 +1041,10 @@ The following creates a dictionary with three items: ``` $myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" ``` -
getGiven a map and a key, get the value from the map. +### get + +Given a map and a key, get the value from the map. ``` get $myDict "name1" @@ -1235,12 +1054,10 @@ The above returns `"value1"` Note that if the key is not found, this operation will simply return `""`. No error will be generated. -
setUse `set` to add a new key/value pair to a dictionary. +### set + +Use `set` to add a new key/value pair to a dictionary. ``` $_ := set $myDict "name4" "value4" @@ -1248,12 +1065,10 @@ $_ := set $myDict "name4" "value4" Note that `set` _returns the dictionary_ (a requirement of Go template functions), so you may need to trap the value as done above with the `$_` assignment. -
unsetGiven a map and a key, delete the key from the map. +### unset + +Given a map and a key, delete the key from the map. ``` $_ := unset $myDict "name4" @@ -1263,24 +1078,20 @@ As with `set`, this returns the dictionary. Note that if the key is not found, this operation will simply return. No error will be generated. -
hasKeyThe `hasKey` function returns `true` if the given dict contains the given key. +### hasKey + +The `hasKey` function returns `true` if the given dict contains the given key. ``` hasKey $myDict "name1" ``` If the key is not found, this returns `false`. -
pluckThe `pluck` function makes it possible to give one key and multiple maps, and +### pluck + +The `pluck` function makes it possible to give one key and multiple maps, and get a list of all of the matches: ``` @@ -1298,12 +1109,10 @@ inserted. A common idiom in Sprig templates is to uses `pluck... | first` to get the first matching key out of a collection of dictionaries. -
digThe `dig` function traverses a nested set of dicts, selecting keys from a list +### dig + +The `dig` function traverses a nested set of dicts, selecting keys from a list of values. It returns a default value if any of the keys are not found at the associated dict. @@ -1331,12 +1140,10 @@ especially since Go's template package's `and` doesn't shortcut. For instance `a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) `dig` accepts its dict argument last in order to support pipelining. -
keysThe `keys` function will return a `list` of all of the keys in one or more `dict` +### keys + +The `keys` function will return a `list` of all of the keys in one or more `dict` types. Since a dictionary is _unordered_, the keys will not be in a predictable order. They can be sorted with `sortAlpha`. @@ -1350,12 +1157,10 @@ function along with `sortAlpha` to get a unqiue, sorted list of keys. ``` keys $myDict $myOtherDict | uniq | sortAlpha ``` -
pickThe `pick` function selects just the given keys out of a dictionary, creating a +### pick + +The `pick` function selects just the given keys out of a dictionary, creating a new `dict`. ``` @@ -1363,12 +1168,10 @@ $new := pick $myDict "name1" "name2" ``` The above returns `{name1: value1, name2: value2}` -
omitThe `omit` function is similar to `pick`, except it returns a new `dict` with all +### omit + +The `omit` function is similar to `pick`, except it returns a new `dict` with all the keys that _do not_ match the given keys. ``` @@ -1376,12 +1179,10 @@ $new := omit $myDict "name1" "name3" ``` The above returns `{name2: value2}` -
valuesThe `values` function is similar to `keys`, except it returns a new `list` with +### values + +The `values` function is similar to `keys`, except it returns a new `list` with all the values of the source `dict` (only one dictionary is supported). ``` @@ -1391,62 +1192,44 @@ $vals := values $myDict The above returns `list["value1", "value2", "value 3"]`. Note that the `values` function gives no guarantees about the result ordering- if you care about this, then use `sortAlpha`. -
## Type Conversion Functions The following type conversion functions are provided by Sprig: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
atoiConvert a string to an integer. -
float64Convert to a `float64`. -
intConvert to an `int` at the system's width. -
int64Convert to an `int64`. -
toDecimalConvert a unix octal to a `int64`. -
toStringConvert to a string. -
toStringsConvert a list, slice, or array to a list of strings. -
+- `atoi`: Convert a string to an integer. +- `float64`: Convert to a `float64`. +- `int`: Convert to an `int` at the system's width. +- `int64`: Convert to an `int64`. +- `toDecimal`: Convert a unix octal to a `int64`. +- `toString`: Convert to a string. +- `toStrings`: Convert a list, slice, or array to a list of strings. Only `atoi` requires that the input be a specific type. The others will attempt to convert from any type to the destination type. For example, `int64` can convert floats to ints, and it can also convert strings to ints. +### toStrings + +Given a list-like collection, produce a slice of strings. + +``` +list 1 2 3 | toStrings +``` + +The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns +them as a list. + +### toDecimal + +Given a unix octal permission, produce a decimal. + +``` +"0777" | toDecimal +``` + +The above converts `0777` to `511` and returns the value as an int64. + ## Path and Filepath Functions While Sprig does not grant access to the filesystem, it does provide functions @@ -1466,56 +1249,44 @@ Examples: [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): `https://example.com/some/content/`, `ftp://example.com/file/`. - - - - - - - - - - - - - - - - - - - - - -
baseReturn the last element of a path. +#### base + +Return the last element of a path. ``` base "foo/bar/baz" ``` The above prints "baz". -
dirReturn the directory, stripping the last part of the path. So `dir "foo/bar/baz"` +#### dir + +Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` returns `foo/bar`. -
cleanClean up a path. +#### clean + +Clean up a path. ``` clean "foo/bar/../baz" ``` The above resolves the `..` and returns `foo/baz`. -
extReturn the file extension. +#### ext + +Return the file extension. ``` ext "foo.bar" ``` The above returns `.bar`. -
isAbsTo check whether a path is absolute, use `isAbs`. -
+#### isAbs + +To check whether a path is absolute, use `isAbs`. ### Filepaths @@ -1531,10 +1302,9 @@ Examples: the filesystem path is separated by the backslash character (`\`): `C:\Users\Username\`, `C:\Program Files\Application\`; - - - - - - - - - - - - - - - - - - - - - -
osBaseReturn the last element of a filepath. +#### osBase + +Return the last element of a filepath. ``` osBase "/foo/bar/baz" @@ -1542,20 +1312,16 @@ osBase "C:\\foo\\bar\\baz" ``` The above prints "baz" on Linux and Windows, respectively. -
osDirReturn the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` +#### osDir + +Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` returns `C:\\foo\\bar` on Windows. -
osCleanClean up a path. +#### osClean + +Clean up a path. ``` osClean "/foo/bar/../baz" @@ -1563,12 +1329,10 @@ osClean "C:\\foo\\bar\\..\\baz" ``` The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. -
osExtReturn the file extension. +#### osExt + +Return the file extension. ``` osExt "/foo.bar" @@ -1576,48 +1340,32 @@ osExt "C:\\foo.bar" ``` The above returns `.bar` on Linux and Windows, respectively. -
osIsAbsTo check whether a file path is absolute, use `osIsAbs`. -
+#### osIsAbs + +To check whether a file path is absolute, use `osIsAbs`. ## Flow Control Functions - - - - - -
failUnconditionally returns an empty `string` and an `error` with the specified +### fail + +Unconditionally returns an empty `string` and an `error` with the specified text. This is useful in scenarios where other conditionals have determined that template rendering should fail. ``` fail "Please accept the end user license agreement" ``` -
## UUID Functions Sprig can generate UUID v4 universally unique IDs. - - - - - -
uuidv4 ``` uuidv4 ``` The above returns a new UUID of the v4 (randomly generated) type. -
## Reflection Functions @@ -1632,63 +1380,35 @@ Sprig provides a set of functions for each. ### Kind Functions - - - - - - - - - - -
kindOfReturns the kind of an object. +There are two Kind functions: `kindOf` returns the kind of an object. ``` kindOf "hello" ``` -The above would return `string`. -
kindIsFor simple tests (like in `if` blocks), the `kindIs` function will let you verify that a value is a particular kind: +The above would return `string`. For simple tests (like in `if` blocks), the +`kindIs` function will let you verify that a value is a particular kind: ``` kindIs "int" 123 ``` The above will return `true` -
### Type Functions Types are slightly harder to work with, so there are three different functions: - - - - - - - - - - - - - - - -
typeOfReturns the underlying type of a value: `typeOf $foo` -
typeIsLike `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` -
typeIsLikeWorks as `typeIs`, except that it also dereferences pointers. -
+- `typeOf` returns the underlying type of a value: `typeOf $foo` +- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` +- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. **Note:** None of these can test whether or not something implements a given interface, since doing so would require compiling the interface in ahead of time. - - - - - -
deepEqualReturns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) +### deepEqual + +`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) Works for non-primitive types as well (compared to the built-in `eq`). @@ -1697,28 +1417,22 @@ deepEqual (list 1 2 3) (list 1 2 3) ``` The above will return `true` -
## Cryptographic and Security Functions Sprig provides a couple of advanced cryptographic functions. - - - - - - - - - - - - - - - - - -
sha1sumThe `sha1sum` function receives a string, and computes it's SHA1 digest. +### sha1sum + +The `sha1sum` function receives a string, and computes it's SHA1 digest. ``` sha1sum "Hello world!" ``` -
sha256sumThe `sha256sum` function receives a string, and computes it's SHA256 digest. +### sha256sum + +The `sha256sum` function receives a string, and computes it's SHA256 digest. ``` sha256sum "Hello world!" @@ -1726,12 +1440,10 @@ sha256sum "Hello world!" The above will compute the SHA 256 sum in an "ASCII armored" format that is safe to print. -
sha512sumThe `sha512sum` function receives a string, and computes it's SHA512 digest. +### sha512sum + +The `sha512sum` function receives a string, and computes it's SHA512 digest. ``` sha512sum "Hello world!" @@ -1739,26 +1451,19 @@ sha512sum "Hello world!" The above will compute the SHA 512 sum in an "ASCII armored" format that is safe to print. -
adler32sumThe `adler32sum` function receives a string, and computes its Adler-32 checksum. +### adler32sum + +The `adler32sum` function receives a string, and computes its Adler-32 checksum. ``` adler32sum "Hello world!" ``` -
## URL Functions - - - - - - - - - -
urlParseParses string for URL and produces dict with URL parts +### urlParse +Parses string for URL and produces dict with URL parts ``` urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" @@ -1776,12 +1481,9 @@ userinfo: 'admin:secret' ``` For more info, check https://golang.org/pkg/net/url/#URL -
urlJoinJoins map (produced by `urlParse`) to produce URL string +### urlJoin +Joins map (produced by `urlParse`) to produce URL string ``` urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") @@ -1791,6 +1493,3 @@ The above returns the following string: ``` proto://host:80/path?query#fragment ``` -
diff --git a/docs/sprig.md b/docs/sprig.md deleted file mode 100644 index be4e6c9c..00000000 --- a/docs/sprig.md +++ /dev/null @@ -1,24 +0,0 @@ -# Template Functions - -ntfy includes a (reduced) version of [Sprig](https://github.com/Masterminds/sprig) to add functions that can be used -when you are using the [message template](publish.md#message-templating) feature. - -Below are the functions that are available to use inside your message/title templates. - -* [String Functions](./sprig/strings.md): `trim`, `trunc`, `substr`, `plural`, etc. - * [String List Functions](./sprig/string_slice.md): `splitList`, `sortAlpha`, etc. -* [Integer Math Functions](./sprig/math.md): `add`, `max`, `mul`, etc. - * [Integer List Functions](./sprig/integer_slice.md): `until`, `untilStep` -* [Date Functions](./sprig/date.md): `now`, `date`, etc. -* [Defaults Functions](./sprig/defaults.md): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` -* [Encoding Functions](./sprig/encoding.md): `b64enc`, `b64dec`, etc. -* [Lists and List Functions](./sprig/lists.md): `list`, `first`, `uniq`, etc. -* [Dictionaries and Dict Functions](./sprig/dicts.md): `get`, `set`, `dict`, `hasKey`, `pluck`, `dig`, etc. -* [Type Conversion Functions](./sprig/conversion.md): `atoi`, `int64`, `toString`, etc. -* [Path and Filepath Functions](./sprig/paths.md): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` -* [Flow Control Functions](./sprig/flow_control.md): `fail` -* Advanced Functions - * [UUID Functions](./sprig/uuid.md): `uuidv4` - * [Reflection](./sprig/reflection.md): `typeOf`, `kindIs`, `typeIsLike`, etc. - * [Cryptographic and Security Functions](./sprig/crypto.md): `sha256sum`, etc. - * [URL](./sprig/url.md): `urlParse`, `urlJoin` diff --git a/docs/sprig/conversion.md b/docs/sprig/conversion.md deleted file mode 100644 index af952682..00000000 --- a/docs/sprig/conversion.md +++ /dev/null @@ -1,36 +0,0 @@ -# Type Conversion Functions - -The following type conversion functions are provided by Sprig: - -- `atoi`: Convert a string to an integer. -- `float64`: Convert to a `float64`. -- `int`: Convert to an `int` at the system's width. -- `int64`: Convert to an `int64`. -- `toDecimal`: Convert a unix octal to a `int64`. -- `toString`: Convert to a string. -- `toStrings`: Convert a list, slice, or array to a list of strings. - -Only `atoi` requires that the input be a specific type. The others will attempt -to convert from any type to the destination type. For example, `int64` can convert -floats to ints, and it can also convert strings to ints. - -## toStrings - -Given a list-like collection, produce a slice of strings. - -``` -list 1 2 3 | toStrings -``` - -The above converts `1` to `"1"`, `2` to `"2"`, and so on, and then returns -them as a list. - -## toDecimal - -Given a unix octal permission, produce a decimal. - -``` -"0777" | toDecimal -``` - -The above converts `0777` to `511` and returns the value as an int64. diff --git a/docs/sprig/crypto.md b/docs/sprig/crypto.md deleted file mode 100644 index c66a269d..00000000 --- a/docs/sprig/crypto.md +++ /dev/null @@ -1,41 +0,0 @@ -# Cryptographic and Security Functions - -Sprig provides a couple of advanced cryptographic functions. - -## sha1sum - -The `sha1sum` function receives a string, and computes it's SHA1 digest. - -``` -sha1sum "Hello world!" -``` - -## sha256sum - -The `sha256sum` function receives a string, and computes it's SHA256 digest. - -``` -sha256sum "Hello world!" -``` - -The above will compute the SHA 256 sum in an "ASCII armored" format that is -safe to print. - -## sha512sum - -The `sha512sum` function receives a string, and computes it's SHA512 digest. - -``` -sha512sum "Hello world!" -``` - -The above will compute the SHA 512 sum in an "ASCII armored" format that is -safe to print. - -## adler32sum - -The `adler32sum` function receives a string, and computes its Adler-32 checksum. - -``` -adler32sum "Hello world!" -``` diff --git a/docs/sprig/date.md b/docs/sprig/date.md deleted file mode 100644 index 7410c08d..00000000 --- a/docs/sprig/date.md +++ /dev/null @@ -1,126 +0,0 @@ -# Date Functions - -## now - -The current date/time. Use this in conjunction with other date functions. - -## ago - -The `ago` function returns duration from time.Now in seconds resolution. - -``` -ago .CreatedAt -``` - -returns in `time.Duration` String() format - -``` -2h34m7s -``` - -## date - -The `date` function formats a date. - -Format the date to YEAR-MONTH-DAY: - -``` -now | date "2006-01-02" -``` - -Date formatting in Go is a [little bit different](https://pauladamsmith.com/blog/2011/05/go_time.html). - -In short, take this as the base date: - -``` -Mon Jan 2 15:04:05 MST 2006 -``` - -Write it in the format you want. Above, `2006-01-02` is the same date, but -in the format we want. - -## dateInZone - -Same as `date`, but with a timezone. - -``` -dateInZone "2006-01-02" (now) "UTC" -``` - -## duration - -Formats a given amount of seconds as a `time.Duration`. - -This returns 1m35s - -``` -duration "95" -``` - -## durationRound - -Rounds a given duration to the most significant unit. Strings and `time.Duration` -gets parsed as a duration, while a `time.Time` is calculated as the duration since. - -This return 2h - -``` -durationRound "2h10m5s" -``` - -This returns 3mo - -``` -durationRound "2400h10m5s" -``` - -## unixEpoch - -Returns the seconds since the unix epoch for a `time.Time`. - -``` -now | unixEpoch -``` - -## dateModify, mustDateModify - -The `dateModify` takes a modification and a date and returns the timestamp. - -Subtract an hour and thirty minutes from the current time: - -``` -now | date_modify "-1.5h" -``` - -If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. - -## htmlDate - -The `htmlDate` function formats a date for inserting into an HTML date picker -input field. - -``` -now | htmlDate -``` - -## htmlDateInZone - -Same as htmlDate, but with a timezone. - -``` -htmlDateInZone (now) "UTC" -``` - -## toDate, mustToDate - -`toDate` converts a string to a date. The first argument is the date layout and -the second the date string. If the string can't be convert it returns the zero -value. -`mustToDate` will return an error in case the string cannot be converted. - -This is useful when you want to convert a string date to another format -(using pipe). The example below converts "2017-12-31" to "31/12/2017". - -``` -toDate "2006-01-02" "2017-12-31" | date "02/01/2006" -``` diff --git a/docs/sprig/defaults.md b/docs/sprig/defaults.md deleted file mode 100644 index b8af1455..00000000 --- a/docs/sprig/defaults.md +++ /dev/null @@ -1,169 +0,0 @@ -# Default Functions - -Sprig provides tools for setting default values for templates. - -## default - -To set a simple default value, use `default`: - -``` -default "foo" .Bar -``` - -In the above, if `.Bar` evaluates to a non-empty value, it will be used. But if -it is empty, `foo` will be returned instead. - -The definition of "empty" depends on type: - -- Numeric: 0 -- String: "" -- Lists: `[]` -- Dicts: `{}` -- Boolean: `false` -- And always `nil` (aka null) - -For structs, there is no definition of empty, so a struct will never return the -default. - -## empty - -The `empty` function returns `true` if the given value is considered empty, and -`false` otherwise. The empty values are listed in the `default` section. - -``` -empty .Foo -``` - -Note that in Go template conditionals, emptiness is calculated for you. Thus, -you rarely need `if empty .Foo`. Instead, just use `if .Foo`. - -## coalesce - -The `coalesce` function takes a list of values and returns the first non-empty -one. - -``` -coalesce 0 1 2 -``` - -The above returns `1`. - -This function is useful for scanning through multiple variables or values: - -``` -coalesce .name .parent.name "Matt" -``` - -The above will first check to see if `.name` is empty. If it is not, it will return -that value. If it _is_ empty, `coalesce` will evaluate `.parent.name` for emptiness. -Finally, if both `.name` and `.parent.name` are empty, it will return `Matt`. - -## all - -The `all` function takes a list of values and returns true if all values are non-empty. - -``` -all 0 1 2 -``` - -The above returns `false`. - -This function is useful for evaluating multiple conditions of variables or values: - -``` -all (eq .Request.TLS.Version 0x0304) (.Request.ProtoAtLeast 2 0) (eq .Request.Method "POST") -``` - -The above will check http.Request is POST with tls 1.3 and http/2. - -## any - -The `any` function takes a list of values and returns true if any value is non-empty. - -``` -any 0 1 2 -``` - -The above returns `true`. - -This function is useful for evaluating multiple conditions of variables or values: - -``` -any (eq .Request.Method "GET") (eq .Request.Method "POST") (eq .Request.Method "OPTIONS") -``` - -The above will check http.Request method is one of GET/POST/OPTIONS. - -## fromJSON, mustFromJSON - -`fromJSON` decodes a JSON document into a structure. If the input cannot be decoded as JSON the function will return an empty string. -`mustFromJSON` will return an error in case the JSON is invalid. - -``` -fromJSON "{\"foo\": 55}" -``` - -## toJSON, mustToJSON - -The `toJSON` function encodes an item into a JSON string. If the item cannot be converted to JSON the function will return an empty string. -`mustToJSON` will return an error in case the item cannot be encoded in JSON. - -``` -toJSON .Item -``` - -The above returns JSON string representation of `.Item`. - -## toPrettyJSON, mustToPrettyJSON - -The `toPrettyJSON` function encodes an item into a pretty (indented) JSON string. - -``` -toPrettyJSON .Item -``` - -The above returns indented JSON string representation of `.Item`. - -## toRawJSON, mustToRawJSON - -The `toRawJSON` function encodes an item into JSON string with HTML characters unescaped. - -``` -toRawJSON .Item -``` - -The above returns unescaped JSON string representation of `.Item`. - -## ternary - -The `ternary` function takes two values, and a test value. If the test value is -true, the first value will be returned. If the test value is empty, the second -value will be returned. This is similar to the c ternary operator. - -### true test value - -``` -ternary "foo" "bar" true -``` - -or - -``` -true | ternary "foo" "bar" -``` - -The above returns `"foo"`. - -### false test value - -``` -ternary "foo" "bar" false -``` - -or - -``` -false | ternary "foo" "bar" -``` - -The above returns `"bar"`. diff --git a/docs/sprig/dicts.md b/docs/sprig/dicts.md deleted file mode 100644 index 5a4490d5..00000000 --- a/docs/sprig/dicts.md +++ /dev/null @@ -1,172 +0,0 @@ -# Dictionaries and Dict Functions - -Sprig provides a key/value storage type called a `dict` (short for "dictionary", -as in Python). A `dict` is an _unorder_ type. - -The key to a dictionary **must be a string**. However, the value can be any -type, even another `dict` or `list`. - -Unlike `list`s, `dict`s are not immutable. The `set` and `unset` functions will -modify the contents of a dictionary. - -## dict - -Creating dictionaries is done by calling the `dict` function and passing it a -list of pairs. - -The following creates a dictionary with three items: - -``` -$myDict := dict "name1" "value1" "name2" "value2" "name3" "value 3" -``` - -## get - -Given a map and a key, get the value from the map. - -``` -get $myDict "name1" -``` - -The above returns `"value1"` - -Note that if the key is not found, this operation will simply return `""`. No error -will be generated. - -## set - -Use `set` to add a new key/value pair to a dictionary. - -``` -$_ := set $myDict "name4" "value4" -``` - -Note that `set` _returns the dictionary_ (a requirement of Go template functions), -so you may need to trap the value as done above with the `$_` assignment. - -## unset - -Given a map and a key, delete the key from the map. - -``` -$_ := unset $myDict "name4" -``` - -As with `set`, this returns the dictionary. - -Note that if the key is not found, this operation will simply return. No error -will be generated. - -## hasKey - -The `hasKey` function returns `true` if the given dict contains the given key. - -``` -hasKey $myDict "name1" -``` - -If the key is not found, this returns `false`. - -## pluck - -The `pluck` function makes it possible to give one key and multiple maps, and -get a list of all of the matches: - -``` -pluck "name1" $myDict $myOtherDict -``` - -The above will return a `list` containing every found value (`[value1 otherValue1]`). - -If the give key is _not found_ in a map, that map will not have an item in the -list (and the length of the returned list will be less than the number of dicts -in the call to `pluck`. - -If the key is _found_ but the value is an empty value, that value will be -inserted. - -A common idiom in Sprig templates is to uses `pluck... | first` to get the first -matching key out of a collection of dictionaries. - -## dig - -The `dig` function traverses a nested set of dicts, selecting keys from a list -of values. It returns a default value if any of the keys are not found at the -associated dict. - -``` -dig "user" "role" "humanName" "guest" $dict -``` - -Given a dict structured like -``` -{ - user: { - role: { - humanName: "curator" - } - } -} -``` - -the above would return `"curator"`. If the dict lacked even a `user` field, -the result would be `"guest"`. - -Dig can be very useful in cases where you'd like to avoid guard clauses, -especially since Go's template package's `and` doesn't shortcut. For instance -`and a.maybeNil a.maybeNil.iNeedThis` will always evaluate -`a.maybeNil.iNeedThis`, and panic if `a` lacks a `maybeNil` field.) - -`dig` accepts its dict argument last in order to support pipelining. - -## keys - -The `keys` function will return a `list` of all of the keys in one or more `dict` -types. Since a dictionary is _unordered_, the keys will not be in a predictable order. -They can be sorted with `sortAlpha`. - -``` -keys $myDict | sortAlpha -``` - -When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq` -function along with `sortAlpha` to get a unqiue, sorted list of keys. - -``` -keys $myDict $myOtherDict | uniq | sortAlpha -``` - -## pick - -The `pick` function selects just the given keys out of a dictionary, creating a -new `dict`. - -``` -$new := pick $myDict "name1" "name2" -``` - -The above returns `{name1: value1, name2: value2}` - -## omit - -The `omit` function is similar to `pick`, except it returns a new `dict` with all -the keys that _do not_ match the given keys. - -``` -$new := omit $myDict "name1" "name3" -``` - -The above returns `{name2: value2}` - -## values - -The `values` function is similar to `keys`, except it returns a new `list` with -all the values of the source `dict` (only one dictionary is supported). - -``` -$vals := values $myDict -``` - -The above returns `list["value1", "value2", "value 3"]`. Note that the `values` -function gives no guarantees about the result ordering- if you care about this, -then use `sortAlpha`. diff --git a/docs/sprig/encoding.md b/docs/sprig/encoding.md deleted file mode 100644 index 1c7a36f8..00000000 --- a/docs/sprig/encoding.md +++ /dev/null @@ -1,6 +0,0 @@ -# Encoding Functions - -Sprig has the following encoding and decoding functions: - -- `b64enc`/`b64dec`: Encode or decode with Base64 -- `b32enc`/`b32dec`: Encode or decode with Base32 diff --git a/docs/sprig/flow_control.md b/docs/sprig/flow_control.md deleted file mode 100644 index 6414640a..00000000 --- a/docs/sprig/flow_control.md +++ /dev/null @@ -1,11 +0,0 @@ -# Flow Control Functions - -## fail - -Unconditionally returns an empty `string` and an `error` with the specified -text. This is useful in scenarios where other conditionals have determined that -template rendering should fail. - -``` -fail "Please accept the end user license agreement" -``` diff --git a/docs/sprig/integer_slice.md b/docs/sprig/integer_slice.md deleted file mode 100644 index ab4bef6d..00000000 --- a/docs/sprig/integer_slice.md +++ /dev/null @@ -1,41 +0,0 @@ -# Integer List Functions - -## until - -The `until` function builds a range of integers. - -``` -until 5 -``` - -The above generates the list `[0, 1, 2, 3, 4]`. - -This is useful for looping with `range $i, $e := until 5`. - -## untilStep - -Like `until`, `untilStep` generates a list of counting integers. But it allows -you to define a start, stop, and step: - -``` -untilStep 3 6 2 -``` - -The above will produce `[3 5]` by starting with 3, and adding 2 until it is equal -or greater than 6. This is similar to Python's `range` function. - -## seq - -Works like the bash `seq` command. -* 1 parameter (end) - will generate all counting integers between 1 and `end` inclusive. -* 2 parameters (start, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by 1. -* 3 parameters (start, step, end) - will generate all counting integers between `start` and `end` inclusive incrementing or decrementing by `step`. - -``` -seq 5 => 1 2 3 4 5 -seq -3 => 1 0 -1 -2 -3 -seq 0 2 => 0 1 2 -seq 2 -2 => 2 1 0 -1 -2 -seq 0 2 10 => 0 2 4 6 8 10 -seq 0 -2 -5 => 0 -2 -4 -``` diff --git a/docs/sprig/lists.md b/docs/sprig/lists.md deleted file mode 100644 index ed8c52b3..00000000 --- a/docs/sprig/lists.md +++ /dev/null @@ -1,188 +0,0 @@ -# Lists and List Functions - -Sprig provides a simple `list` type that can contain arbitrary sequential lists -of data. This is similar to arrays or slices, but lists are designed to be used -as immutable data types. - -Create a list of integers: - -``` -$myList := list 1 2 3 4 5 -``` - -The above creates a list of `[1 2 3 4 5]`. - -## first, mustFirst - -To get the head item on a list, use `first`. - -`first $myList` returns `1` - -`first` panics if there is a problem while `mustFirst` returns an error to the -template engine if there is a problem. - -## rest, mustRest - -To get the tail of the list (everything but the first item), use `rest`. - -`rest $myList` returns `[2 3 4 5]` - -`rest` panics if there is a problem while `mustRest` returns an error to the -template engine if there is a problem. - -## last, mustLast - -To get the last item on a list, use `last`: - -`last $myList` returns `5`. This is roughly analogous to reversing a list and -then calling `first`. - -`last` panics if there is a problem while `mustLast` returns an error to the -template engine if there is a problem. - -## initial, mustInitial - -This compliments `last` by returning all _but_ the last element. -`initial $myList` returns `[1 2 3 4]`. - -`initial` panics if there is a problem while `mustInitial` returns an error to the -template engine if there is a problem. - -## append, mustAppend - -Append a new item to an existing list, creating a new list. - -``` -$new = append $myList 6 -``` - -The above would set `$new` to `[1 2 3 4 5 6]`. `$myList` would remain unaltered. - -`append` panics if there is a problem while `mustAppend` returns an error to the -template engine if there is a problem. - -## prepend, mustPrepend - -Push an element onto the front of a list, creating a new list. - -``` -prepend $myList 0 -``` - -The above would produce `[0 1 2 3 4 5]`. `$myList` would remain unaltered. - -`prepend` panics if there is a problem while `mustPrepend` returns an error to the -template engine if there is a problem. - -## concat - -Concatenate arbitrary number of lists into one. - -``` -concat $myList ( list 6 7 ) ( list 8 ) -``` - -The above would produce `[1 2 3 4 5 6 7 8]`. `$myList` would remain unaltered. - -## reverse, mustReverse - -Produce a new list with the reversed elements of the given list. - -``` -reverse $myList -``` - -The above would generate the list `[5 4 3 2 1]`. - -`reverse` panics if there is a problem while `mustReverse` returns an error to the -template engine if there is a problem. - -## uniq, mustUniq - -Generate a list with all of the duplicates removed. - -``` -list 1 1 1 2 | uniq -``` - -The above would produce `[1 2]` - -`uniq` panics if there is a problem while `mustUniq` returns an error to the -template engine if there is a problem. - -## without, mustWithout - -The `without` function filters items out of a list. - -``` -without $myList 3 -``` - -The above would produce `[1 2 4 5]` - -Without can take more than one filter: - -``` -without $myList 1 3 5 -``` - -That would produce `[2 4]` - -`without` panics if there is a problem while `mustWithout` returns an error to the -template engine if there is a problem. - -## has, mustHas - -Test to see if a list has a particular element. - -``` -has 4 $myList -``` - -The above would return `true`, while `has "hello" $myList` would return false. - -`has` panics if there is a problem while `mustHas` returns an error to the -template engine if there is a problem. - -## compact, mustCompact - -Accepts a list and removes entries with empty values. - -``` -$list := list 1 "a" "foo" "" -$copy := compact $list -``` - -`compact` will return a new list with the empty (i.e., "") item removed. - -`compact` panics if there is a problem and `mustCompact` returns an error to the -template engine if there is a problem. - -## slice, mustSlice - -To get partial elements of a list, use `slice list [n] [m]`. It is -equivalent of `list[n:m]`. - -- `slice $myList` returns `[1 2 3 4 5]`. It is same as `myList[:]`. -- `slice $myList 3` returns `[4 5]`. It is same as `myList[3:]`. -- `slice $myList 1 3` returns `[2 3]`. It is same as `myList[1:3]`. -- `slice $myList 0 3` returns `[1 2 3]`. It is same as `myList[:3]`. - -`slice` panics if there is a problem while `mustSlice` returns an error to the -template engine if there is a problem. - -## chunk - -To split a list into chunks of given size, use `chunk size list`. This is useful for pagination. - -``` -chunk 3 (list 1 2 3 4 5 6 7 8) -``` - -This produces list of lists `[ [ 1 2 3 ] [ 4 5 6 ] [ 7 8 ] ]`. - -## A Note on List Internals - -A list is implemented in Go as a `[]interface{}`. For Go developers embedding -Sprig, you may pass `[]interface{}` items into your template context and be -able to use all of the `list` functions on those items. diff --git a/docs/sprig/math.md b/docs/sprig/math.md deleted file mode 100644 index b08d0a2f..00000000 --- a/docs/sprig/math.md +++ /dev/null @@ -1,78 +0,0 @@ -# Integer Math Functions - -The following math functions operate on `int64` values. - -## add - -Sum numbers with `add`. Accepts two or more inputs. - -``` -add 1 2 3 -``` - -## add1 - -To increment by 1, use `add1` - -## sub - -To subtract, use `sub` - -## div - -Perform integer division with `div` - -## mod - -Modulo with `mod` - -## mul - -Multiply with `mul`. Accepts two or more inputs. - -``` -mul 1 2 3 -``` - -## max - -Return the largest of a series of integers: - -This will return `3`: - -``` -max 1 2 3 -``` - -## min - -Return the smallest of a series of integers. - -`min 1 2 3` will return `1` - -## floor - -Returns the greatest float value less than or equal to input value - -`floor 123.9999` will return `123.0` - -## ceil - -Returns the greatest float value greater than or equal to input value - -`ceil 123.001` will return `124.0` - -## round - -Returns a float value with the remainder rounded to the given number to digits after the decimal point. - -`round 123.555555 3` will return `123.556` - -## randInt -Returns a random integer value from min (inclusive) to max (exclusive). - -``` -randInt 12 30 -``` - -The above will produce a random number in the range [12,30]. diff --git a/docs/sprig/paths.md b/docs/sprig/paths.md deleted file mode 100644 index f847e357..00000000 --- a/docs/sprig/paths.md +++ /dev/null @@ -1,114 +0,0 @@ -# Path and Filepath Functions - -While Sprig does not grant access to the filesystem, it does provide functions -for working with strings that follow file path conventions. - -## Paths - -Paths separated by the slash character (`/`), processed by the `path` package. - -Examples: - -* The [Linux](https://en.wikipedia.org/wiki/Linux) and - [MacOS](https://en.wikipedia.org/wiki/MacOS) - [filesystems](https://en.wikipedia.org/wiki/File_system): - `/home/user/file`, `/etc/config`; -* The path component of - [URIs](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier): - `https://example.com/some/content/`, `ftp://example.com/file/`. - -### base - -Return the last element of a path. - -``` -base "foo/bar/baz" -``` - -The above prints "baz". - -### dir - -Return the directory, stripping the last part of the path. So `dir "foo/bar/baz"` -returns `foo/bar`. - -### clean - -Clean up a path. - -``` -clean "foo/bar/../baz" -``` - -The above resolves the `..` and returns `foo/baz`. - -### ext - -Return the file extension. - -``` -ext "foo.bar" -``` - -The above returns `.bar`. - -### isAbs - -To check whether a path is absolute, use `isAbs`. - -## Filepaths - -Paths separated by the `os.PathSeparator` variable, processed by the `path/filepath` package. - -These are the recommended functions to use when parsing paths of local filesystems, usually when dealing with local files, directories, etc. - -Examples: - -* Running on Linux or MacOS the filesystem path is separated by the slash character (`/`): - `/home/user/file`, `/etc/config`; -* Running on [Windows](https://en.wikipedia.org/wiki/Microsoft_Windows) - the filesystem path is separated by the backslash character (`\`): - `C:\Users\Username\`, `C:\Program Files\Application\`; - -### osBase - -Return the last element of a filepath. - -``` -osBase "/foo/bar/baz" -osBase "C:\\foo\\bar\\baz" -``` - -The above prints "baz" on Linux and Windows, respectively. - -### osDir - -Return the directory, stripping the last part of the path. So `osDir "/foo/bar/baz"` -returns `/foo/bar` on Linux, and `osDir "C:\\foo\\bar\\baz"` -returns `C:\\foo\\bar` on Windows. - -### osClean - -Clean up a path. - -``` -osClean "/foo/bar/../baz" -osClean "C:\\foo\\bar\\..\\baz" -``` - -The above resolves the `..` and returns `foo/baz` on Linux and `C:\\foo\\baz` on Windows. - -### osExt - -Return the file extension. - -``` -osExt "/foo.bar" -osExt "C:\\foo.bar" -``` - -The above returns `.bar` on Linux and Windows, respectively. - -### osIsAbs - -To check whether a file path is absolute, use `osIsAbs`. diff --git a/docs/sprig/reflection.md b/docs/sprig/reflection.md deleted file mode 100644 index 51e167aa..00000000 --- a/docs/sprig/reflection.md +++ /dev/null @@ -1,50 +0,0 @@ -# Reflection Functions - -Sprig provides rudimentary reflection tools. These help advanced template -developers understand the underlying Go type information for a particular value. - -Go has several primitive _kinds_, like `string`, `slice`, `int64`, and `bool`. - -Go has an open _type_ system that allows developers to create their own types. - -Sprig provides a set of functions for each. - -## Kind Functions - -There are two Kind functions: `kindOf` returns the kind of an object. - -``` -kindOf "hello" -``` - -The above would return `string`. For simple tests (like in `if` blocks), the -`kindIs` function will let you verify that a value is a particular kind: - -``` -kindIs "int" 123 -``` - -The above will return `true` - -## Type Functions - -Types are slightly harder to work with, so there are three different functions: - -- `typeOf` returns the underlying type of a value: `typeOf $foo` -- `typeIs` is like `kindIs`, but for types: `typeIs "*io.Buffer" $myVal` -- `typeIsLike` works as `typeIs`, except that it also dereferences pointers. - -**Note:** None of these can test whether or not something implements a given -interface, since doing so would require compiling the interface in ahead of time. - -## deepEqual - -`deepEqual` returns true if two values are ["deeply equal"](https://golang.org/pkg/reflect/#DeepEqual) - -Works for non-primitive types as well (compared to the built-in `eq`). - -``` -deepEqual (list 1 2 3) (list 1 2 3) -``` - -The above will return `true` diff --git a/docs/sprig/string_slice.md b/docs/sprig/string_slice.md deleted file mode 100644 index 96c0c83b..00000000 --- a/docs/sprig/string_slice.md +++ /dev/null @@ -1,72 +0,0 @@ -# String List Functions - -These function operate on or generate slices of strings. In Go, a slice is a -growable array. In Sprig, it's a special case of a `list`. - -## join - -Join a list of strings into a single string, with the given separator. - -``` -list "hello" "world" | join "_" -``` - -The above will produce `hello_world` - -`join` will try to convert non-strings to a string value: - -``` -list 1 2 3 | join "+" -``` - -The above will produce `1+2+3` - -## splitList and split - -Split a string into a list of strings: - -``` -splitList "$" "foo$bar$baz" -``` - -The above will return `[foo bar baz]` - -The older `split` function splits a string into a `dict`. It is designed to make -it easy to use template dot notation for accessing members: - -``` -$a := split "$" "foo$bar$baz" -``` - -The above produces a map with index keys. `{_0: foo, _1: bar, _2: baz}` - -``` -$a._0 -``` - -The above produces `foo` - -## splitn - -`splitn` function splits a string into a `dict` with `n` keys. It is designed to make -it easy to use template dot notation for accessing members: - -``` -$a := splitn "$" 2 "foo$bar$baz" -``` - -The above produces a map with index keys. `{_0: foo, _1: bar$baz}` - -``` -$a._0 -``` - -The above produces `foo` - -## sortAlpha - -The `sortAlpha` function sorts a list of strings into alphabetical (lexicographical) -order. - -It does _not_ sort in place, but returns a sorted copy of the list, in keeping -with the immutability of lists. diff --git a/docs/sprig/strings.md b/docs/sprig/strings.md deleted file mode 100644 index 784392f1..00000000 --- a/docs/sprig/strings.md +++ /dev/null @@ -1,309 +0,0 @@ -# String Functions - -Sprig has a number of string manipulation functions. - -## trim - -The `trim` function removes space from either side of a string: - -``` -trim " hello " -``` - -The above produces `hello` - -## trimAll - -Remove given characters from the front or back of a string: - -``` -trimAll "$" "$5.00" -``` - -The above returns `5.00` (as a string). - -## trimSuffix - -Trim just the suffix from a string: - -``` -trimSuffix "-" "hello-" -``` - -The above returns `hello` - -## trimPrefix - -Trim just the prefix from a string: - -``` -trimPrefix "-" "-hello" -``` - -The above returns `hello` - -## upper - -Convert the entire string to uppercase: - -``` -upper "hello" -``` - -The above returns `HELLO` - -## lower - -Convert the entire string to lowercase: - -``` -lower "HELLO" -``` - -The above returns `hello` - -## title - -Convert to title case: - -``` -title "hello world" -``` - -The above returns `Hello World` - -## repeat - -Repeat a string multiple times: - -``` -repeat 3 "hello" -``` - -The above returns `hellohellohello` - -## substr - -Get a substring from a string. It takes three parameters: - -- start (int) -- end (int) -- string (string) - -``` -substr 0 5 "hello world" -``` - -The above returns `hello` - -## trunc - -Truncate a string (and add no suffix) - -``` -trunc 5 "hello world" -``` - -The above produces `hello`. - -``` -trunc -5 "hello world" -``` - -The above produces `world`. - -## contains - -Test to see if one string is contained inside of another: - -``` -contains "cat" "catch" -``` - -The above returns `true` because `catch` contains `cat`. - -## hasPrefix and hasSuffix - -The `hasPrefix` and `hasSuffix` functions test whether a string has a given -prefix or suffix: - -``` -hasPrefix "cat" "catch" -``` - -The above returns `true` because `catch` has the prefix `cat`. - -## quote and squote - -These functions wrap a string in double quotes (`quote`) or single quotes -(`squote`). - -## cat - -The `cat` function concatenates multiple strings together into one, separating -them with spaces: - -``` -cat "hello" "beautiful" "world" -``` - -The above produces `hello beautiful world` - -## indent - -The `indent` function indents every line in a given string to the specified -indent width. This is useful when aligning multi-line strings: - -``` -indent 4 $lots_of_text -``` - -The above will indent every line of text by 4 space characters. - -## nindent - -The `nindent` function is the same as the indent function, but prepends a new -line to the beginning of the string. - -``` -nindent 4 $lots_of_text -``` - -The above will indent every line of text by 4 space characters and add a new -line to the beginning. - -## replace - -Perform simple string replacement. - -It takes three arguments: - -- string to replace -- string to replace with -- source string - -``` -"I Am Henry VIII" | replace " " "-" -``` - -The above will produce `I-Am-Henry-VIII` - -## plural - -Pluralize a string. - -``` -len $fish | plural "one anchovy" "many anchovies" -``` - -In the above, if the length of the string is 1, the first argument will be -printed (`one anchovy`). Otherwise, the second argument will be printed -(`many anchovies`). - -The arguments are: - -- singular string -- plural string -- length integer - -NOTE: Sprig does not currently support languages with more complex pluralization -rules. And `0` is considered a plural because the English language treats it -as such (`zero anchovies`). The Sprig developers are working on a solution for -better internationalization. - -## regexMatch, mustRegexMatch - -Returns true if the input string contains any match of the regular expression. - -``` -regexMatch "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" "test@acme.com" -``` - -The above produces `true` - -`regexMatch` panics if there is a problem and `mustRegexMatch` returns an error to the -template engine if there is a problem. - -## regexFindAll, mustRegexFindAll - -Returns a slice of all matches of the regular expression in the input string. -The last parameter n determines the number of substrings to return, where -1 means return all matches - -``` -regexFindAll "[2,4,6,8]" "123456789" -1 -``` - -The above produces `[2 4 6 8]` - -`regexFindAll` panics if there is a problem and `mustRegexFindAll` returns an error to the -template engine if there is a problem. - -## regexFind, mustRegexFind - -Return the first (left most) match of the regular expression in the input string - -``` -regexFind "[a-zA-Z][1-9]" "abcd1234" -``` - -The above produces `d1` - -`regexFind` panics if there is a problem and `mustRegexFind` returns an error to the -template engine if there is a problem. - -## regexReplaceAll, mustRegexReplaceAll - -Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement. -Inside string replacement, $ signs are interpreted as in Expand, so for instance $1 represents the text of the first submatch - -``` -regexReplaceAll "a(x*)b" "-ab-axxb-" "${1}W" -``` - -The above produces `-W-xxW-` - -`regexReplaceAll` panics if there is a problem and `mustRegexReplaceAll` returns an error to the -template engine if there is a problem. - -## regexReplaceAllLiteral, mustRegexReplaceAllLiteral - -Returns a copy of the input string, replacing matches of the Regexp with the replacement string replacement -The replacement string is substituted directly, without using Expand - -``` -regexReplaceAllLiteral "a(x*)b" "-ab-axxb-" "${1}" -``` - -The above produces `-${1}-${1}-` - -`regexReplaceAllLiteral` panics if there is a problem and `mustRegexReplaceAllLiteral` returns an error to the -template engine if there is a problem. - -## regexSplit, mustRegexSplit - -Slices the input string into substrings separated by the expression and returns a slice of the substrings between those expression matches. The last parameter `n` determines the number of substrings to return, where `-1` means return all matches - -``` -regexSplit "z+" "pizza" -1 -``` - -The above produces `[pi a]` - -`regexSplit` panics if there is a problem and `mustRegexSplit` returns an error to the -template engine if there is a problem. - -## regexQuoteMeta - -Returns a string that escapes all regular expression metacharacters inside the argument text; -the returned string is a regular expression matching the literal text. - -``` -regexQuoteMeta "1.2.3" -``` - -The above produces `1\.2\.3` - -## See Also... - -The [Conversion Functions](conversion.md) contain functions for converting strings. The [String List Functions](string_slice.md) contains -functions for working with an array of strings. diff --git a/docs/sprig/url.md b/docs/sprig/url.md deleted file mode 100644 index 21d54a29..00000000 --- a/docs/sprig/url.md +++ /dev/null @@ -1,33 +0,0 @@ -# URL Functions - -## urlParse -Parses string for URL and produces dict with URL parts - -``` -urlParse "http://admin:secret@server.com:8080/api?list=false#anchor" -``` - -The above returns a dict, containing URL object: -```yaml -scheme: 'http' -host: 'server.com:8080' -path: '/api' -query: 'list=false' -opaque: nil -fragment: 'anchor' -userinfo: 'admin:secret' -``` - -For more info, check https://golang.org/pkg/net/url/#URL - -## urlJoin -Joins map (produced by `urlParse`) to produce URL string - -``` -urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "http") -``` - -The above returns the following string: -``` -proto://host:80/path?query#fragment -``` diff --git a/docs/sprig/uuid.md b/docs/sprig/uuid.md deleted file mode 100644 index 1b57a330..00000000 --- a/docs/sprig/uuid.md +++ /dev/null @@ -1,9 +0,0 @@ -# UUID Functions - -Sprig can generate UUID v4 universally unique IDs. - -``` -uuidv4 -``` - -The above returns a new UUID of the v4 (randomly generated) type. diff --git a/docs/static/img/android-screenshot-template-custom.png b/docs/static/img/android-screenshot-template-custom.png new file mode 100644 index 0000000000000000000000000000000000000000..8325e9a4a580ee877dc620fcec04ac9bbb47dfc5 GIT binary patch literal 45032 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/android-screenshot-template-predefined.png b/docs/static/img/android-screenshot-template-predefined.png new file mode 100644 index 0000000000000000000000000000000000000000..ba77c31d3ec0f59a790e094192174140b7b34b66 GIT binary patch literal 86828 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 Qhd@cz)78&qol`;+09SQgDF6Tf literal 0 HcmV?d00001 diff --git a/docs/static/img/screenshot-github-webhook-config.png b/docs/static/img/screenshot-github-webhook-config.png new file mode 100644 index 0000000000000000000000000000000000000000..46b784b3dee587b776b67a3be8be45ff2d8c6d89 GIT binary patch literal 98734 zcmeEu^;29y*Jc6%5}X7Jg9Hff?(PH&1h)VoxX<7gY;bpXx53@r-QC@X!P&gEn{U7W zV5_#iU%Kk_t**NF=_AkSbNU7;DM+Cq6Cl5N^9D^uT3q?fn|FtA-n_kdkN9`yT9_~3 z%^UAGGU6gXob^u9jGb51y6Rx(^Tun>x6f<5K}nytZMT>?CYTFk2!C5oF|$#jv(fdLcd{Sz7lZ020viFLrnmK9B5gl9_uc{oJQ!sTAVftM4aw9=t z{X1UmzLh{z*#`vpxVs+GYhz-vv z&sl<*pbu)w7>#CKX^0Qf%DFk-ZlV1&?qH6O85b`C-N4uKd)(gUEw8dR<+kLC4k;@r zOB!TS$Zl;gnlt|D<|$VVU&y2-b7tWKxU%VUJw2BI8P_1>xT35O=1DqtN z`{s$3q?m+v0=W{!fat zdw+y!;>+T@xmdykM?%ETE#k*rzsZeAtlHTZa&o?PIJZ!;yls`#4gi8a=;fPVNYlto zZAdiX7Tp`G`hGf&JPVQ2wfUl2o+u>Y3pQLUDAM}6oH$RG3&5>n`BdOAOPB>eNF$tX;=KQ(6a+`nooWDLh)Y1RzIuj8m1w6 z6yk6|aF&Ily&{^!40L91(apW+MJemE_F=7m*c>OY`cTEHeE9&PPZ#v^PYC{+yNgY} zJ4@sK@!(}FQXz*%(p8pKugx{AfJtv#Rg|_$ys@pV3g2Li1MP4qGOn~E#)Jvfrj)CB z!X$6Ns28#eX!E)f{ZM=VG81g1`I*Y=6*)N85?j-6#&|%bBr*~+VxLLxu7K`}XfhY^ zhoZ%S=@#<4Py3l#asGGC!%+F50g0?zqU)ejdint=S^JNAGLK>-u#-GEWMkeR_XL35 z*Jn~)5o=1lw#eb<#J)^}BQ__kstk7=~ii#4eA^%%YdIf8`9r`$;U>~4M3P1)Re4Fw%NTs{gM9s3HUkm?!`v@Nx~5m>JGj;QA{ zZ@%kj_tZvBZhI@GP$p(npBw(2>GB0Ki``)hc}3f_a8OwJBnzOx7U(d6A4!_N zJ~_v$nbujWk*4(v6M8x$IP~90{gTEcOcU7H)5lL4+weH* z846e++UyjuwZHT(vEe2F;Zuh>bv{9?7A4fRDNGftXTvxlhVENbB5;N!hKVZ^R^Aq6awRqtyi_{<=51t&_wlo7YdI1-oys}w_atx ziHo>4^(ns^?$jEVWR_#Erz-kRbNfRGOP@~%xbLh6`cSHsc6CX0n^;NG_Im*du!1Br z?kFu<@DF2dTj8Mgh|&i>L?ME#o_O|akbC`60_{w^*uuJ;LN^zJYC+N5M~c0=>zHm* zC!m%+pfL%?LRMV|k zwZbWLShmsNv*crfDO@^50kJ;@e5Ini`;r-%@(t}Y7i`txCSUukS^_Pvy<2MD&DV#C z`DNHZkB^B%>$>MR&FYND2c8zD59aCN#>V2uzp@`}>>y;gu0rnh>J~knrKA%wLUmPK zyTs-JD#=2GMlgQw;=%{wq<{4HaQqYr?Xbqb7r*vPC;piq2}2|p7{4_@ocEO4=p5Rh zA1a@MDp0n?^4#cNT{`*mK+eONYVqb*6pM7D133}Nu#Nlk^TIozP;TF0f<(iGE@MEc z+Lq##)2ovRueHhGa5eJb*H>o7xAOA^MmuXj@llzMj9q3Kf~h57#UYPt{CMv4WBp)| zX*CT|4lFn4W&iTdck|wL((b^XA0sJXGhj)Xa3$pz@?tiQZCXlbIv?wVjTkFGWs*-< zyTW%va2NIkMcaVLgt*G*Cy+8(HfQd3HIbPqyLxFwOWM?fBoUo({8NgmXcNOjq(Yo# z=w!*KOYQq>FVA$J)epa06Od6R=1z=>LAZHFr=#vC%}KtRkJFPs707%X_m)m*OjRJK z&5i?)u7z-$QmFmHqa%&==ve`ngS9MkLQ{dyi4iJnyn}C}0Q_#&;>EKQV?MdZ_!I0a z*gFjfSq6_Ufy8|10zy%uoE{f>k7H!HbYe@Z@PWu2?|jP(w{`It+{BG`4x$bgs%Tbr zZKbLxNz-KSF~zhG681N6gFk3j9Qhc@8{pQBpTOfuK?uiPFEDNoCr0}(%1mQFp<6Yk644b(pa_VX_~xzt5qP?>1@WC0!WD?n0~~1X~OSm`G^(0}VWEDLGLj5Hz_V_Jr=c zZ|cI;1yGlqw~++qBmbo(&os}Kq`ERR3dGW^h!S3UxuV(F3OjfP11-+0Chh5wejYLu z4K6GQUQq=Wz#1j4omy%hq_uzvdR}7vmS}Lc89P4LJhqPi>5y2JW<{EuF>$h7416tC z(A`$!O3VOnE&vytu8&8mlCB;{I%lEjL&Kbs5}P)Xzj_1D z>qrG{ZwCNnw-hnr;f5MUHp2xVW5~_ut!QnOPm?O9ci;c71EPtoS zy%sOfq>B17uAQlXeeLKI)1pv&blZ>MFDq0*NyH~r!(t8gU8h>Gl{X_)C|7tupLXSv zB3gx6zdvAwkC;XV9hWpT;6UMT#8QMDQH2W{`y*qSNW2X7byqov4D1zqw9=yth1L=h zEO4e~iM&ICf4NtCARav)!;dTxXW=(oP)FO#3%LJRCLFX`FYxY;2M9Rg}2r9VA_|>HE47;`V@Np%DN?W)B_g36+<* zi!sm-mF*cpBG}Pr*AXu1^>fLuyyPO^+TdhD_|!JmPbbE$Y&2|pYEDVG^L*6A`)Ry1 zJq^Yf5<5RxZm;(n#IM_Z5E*}hr&a1pJ4wt+c;gOr}D zP0t72oj)MZgZ5%2`^Z@)Ep_8_Hr5jQHXE*|j04;}ILLHw*5|+GPRC~Lwwh4k6NvAj)a*nS`xLe$@`BQVV@~tW#t?L&09`Umu^^gnsS)9S5=%yi+ z#y=xjZi=^weVXL8v+QCAFNCKZ&Ip&8#H!9TYLO39$sjl3*a4sCYTreKSBMuz@Q&!h`{_jJ7FYDB5aS5&4QnM>-GD6;52q() zyE9s+S*O;=>*EnRGv2DpydASig(GP!wLk!M8;F@wr=;EBcWWaJ81ML&v%$#9lpe-d zb4#Xw;VT3}LvAi(3qVrGA0Dwv>Y!5}{vBVPuS?Im79t7 z0bDw53z&b!i4%)c4PA49k}j<0QyO>~eyFoygT50XIN2Tp$a{QF3wpXY4um}s9m;wWLajfl&OW1Q429#L(m_s&y4zG zJ+DLK&M~eVR;pUJl+)M=KS$GlJ&B~lNKmFPl~e;#PYhADe%;9F=w`DHxVl*)tUv*=`-fdUE(tAMJ0nmcp&3ZNCr-Bbbfr2q$ zsznYrGjRxYj6aL)lY&_p=VqoUq37Naz%AvQQ0lOH^U_cD*M zZW(@Xsdue2&&;>N-}VArBWW&OzVTQV|Ebj;0gF1NYWo8j(`>0nuXrqrxM0}^mFy>K z?&k*)E;H;Q$9%&5A^Tl6g4GCN@pqG^op)t zH|7ePbaHxRUN1vFB)XB0bAMGkLYs#L6wNEVOw(^i$`3|{B_(>&57+gpHsJ{$?ESSB zPYd$g=D!|_q2}`x&BQ~?=AydmI?j}C)%(L}Oe6AK?PjjeUa|Yn%RWHM+5kw{l))%mEHOb=5mARauD@?I*2eBP8rXe4(%oc= z;I3w}nQ~Hmt(g+9>^(Pq(|Yy7uNhSKn`!G#kOSfBddae7rXxQ5MZWS#C$+60Nixji z8VS%%l%TeKLCv#nc+W$_#M0!EDOkyZaX3SxL9nWY?y;ieb-~)B**YM6^md+S$3v9Y z&b?s0w{j2b0SZFy_G8lDd`fj}+AOPu18>PHe6Au%6CSqp(&e5rc@c4MUBN7D>}D>$pFt}CA;hYgdJmb^3YzP1>L*qdF94XP7v&W~U9#fKEm;L} z>S?9&5v(j}>qy&{&y-5#jMiHXB_8j>_FS|7vz(V|+gkchN;xHg{hr3ufDon~ZEXUdJi z90}D|!&L{K1{#{~Ahw6B{3bp1W>!pUuwgrKhF{w6PBjBV&QvreIm|kN0+Fbj(&i)f z(h#WaJPQqX4uHU<618BgU|T<6vpgqmA=T8Pp#<)fafUsYuD`xs)s`J_#{1~M%T^hD z{f*fq#B7b1j_s1a02QH8D^x%2C+ZSheIcfeAiw(Ekw5a8y{0=mFR_XZ?y(Q8HtDL+ zUampUp7=KAmXLy6&(=A6etoi*=8qr2cQb}3ue{S+yEw`i19kg1?M4C-jZTaOnwH7pX{6DEiFaW>4_sMJ#;VQWU%$3*)lky=Kt5 zfzoubjnnK-p3$n`Sl@omJt!?`zYp)rfvVtYM7&-Au@(o)VGTtiH-G1a4yR&`_7#U3 zEOu?=2ywiPWgy;@Z=bZUz7&bZ(wy@Nh5m#nZ=F+$1h{Owe!^v6d8KmCW7QSOS$*-P z;s@ce@vLcL#gXlN+3qY-o_;QGI7CC07Hf*7uMQe+^>d7Mus`Kpg4UX)c9dl*ERjn zGZ(KqQC=h%;(?GA0l|>Kt$8D2_PGzaG*jU{rscnQakGC#_2F=9d!}!H^x3NPV_U|t z8~FLWlwC(TCEpFIga)%y#?SRWx1@OCkt9KVwy;AaPHHdBEPO9OmC{AkFOk%cSI+yc zT3RLGrbSuTyWnx8FN8eX#v+VKVPJgn=L6z&isI{q3vGGsva4Osqd&0tj^8f@Ee>rh z0Sv6}prwJlIYqUB%(kkkmARFa<~Lxcpy%Reki+_uXt<+HkHXaG+t!;E&2-k=<S+!=kUJ zF1X`6CE1c8gD9xW*x(ngTGiX}W_K4C8ZzLxZA`k?UdV%0Q;znyXZ-Dfe;-sdEGx#Q zpT;?nR=`N!clievI=V+gFmBpRB`sQFRF%q$3|WNg9&*ig=z?pHS0YvRVthtl@MK+%%ttV#1M<=rp!3zc3aZQR+@ zT{e-bxvy>9LPUHf7=9@R%dJaB%Q-)uTZG7Rr_G}wZS7`jTBKsOjDt7v>!KhswFnNY z2#f3Q4LN{0tzbNpxt#J0{+f9jRPx&usq`#Z!9oO6xwQxz575E(L+1fn!p{Ec-x3%4 z8bjs5@jNx-Kvy{re3;Ea+3qyKxAE4ewT`5J4&}M=Sw@t%`+6_8<6yoU`z~!oWz^;y zy7GvRzfUOK-`wZjL-QswOPL_1MT7S13hy-QuT@GQ-v!@Q+-s=3o_&QzpwUqgtA~S+> zI^7iHz@E`D*kzwY&cB*JR1GiH>c_VK3fly4F3LiwXzC0~MJ}q-dGSUbiWqb_bRh}e zpO8Ry9Fwq?5$#jEvJvbV&i#{i=;NgXw-*%vYg?{;_QI6(YP4>BDRiH98->A>ivdl& zRL}WR;oB9&1ZZTGxbwXp5H3(nSYDhlcI6L`p3YEtm8=ZYor|fMfRuZ3m*lEw;Em`s zZy8CG3kb>aTR!rLRHdbcYF& zrcp1K+Dy<1ql3E$zGxhg?&135aNQi!`zu#CM$EJ#)P>tHKdszs#yUO9I7ce~gp}?e zNlnm*dh+c^DN%z<&-9&*uO)fO>IUmY=S3$f2J(v^P5c*2$$T|jN#l;oe{g_I@@V-8s`*A;c)WEf4 zt>0Tkchi^&0!Y(#Yy#*m_P-;C;88poxRc=uO%S+IJtsygu&S#Dia22RO`MQJp1nS{ z-|t7|kLR+QH872D`J?ytQgFNQ_u_BKyjc6ec|o++g%lKfFWFUWy3ECRU6Q~Ul*dC- z^^D!y6T3jtnJM>17gJ__+;-gE`+106(^aVe5AiEopXF*lHH7D$NbH>iPk&$)Gyk`W zHHeQncxFjC#uYaWb)4$c+-WU3gj3)Cd%z7J(!}4QBc+PsOvXdpOMUPxcYP+;(Lif8 zP$Vn_W2F^-W9#UxRK$B{7j=*3lA4vR74A}kg%!g>DRr?Fui_OL^per2ssi9fYNke?W}kjD%!AEWH<){A6A+tfk{tJfbO zq-nXd)$c!Xe(kgqC46BQT~|?hkT=sM%BPv!9(K=NBoNh74sg&n`C=)_|H7h0GA48V ztVX|mzP*iA#S34cLa%RqQ68ya@|}f}KAbBann6iv*lWaqe1&{bbeHzZM~UtgnT|p2 z^$Pf|=Y^HQ;}sQpkHO9f19z^>xU;8}GnK4|sv4?vJ^GPo@vpI>)`o2+b0MBN>z-)gJe@3v{bc5fsJa03CqWaen}? zE886>ye!ElA9~!?Jq@T$TM2DH#QA~o-Q)MRvd`}ID=MYX)5pn-I#F*g?-r(2rQyX- zs^0zZ)u#V@TBSNz%klAzOF#ZOZoK7B&2 zyop7wRyauWU?d6Rge&r?fg2GrOSbO3C*&V0;!T^3W^DuIy#(Qu~)RQI-Aj}=1B zw1~%g%_G%*&A}JtVsqK-A0&9tB5_s8MkN&V-aj^KI8p-i-IK!N)URx5igJh8ii2kf zN`*4YGSr{?7`p*R#vIAYI&BF#j~-^pv-K!ZX4?`U{1GS`uIWiqn2K4>zv1i!g7lmO zFx2C;rrD+~saxNCVaaz%O{{;)r*)cQB`Ff4VCA zOS4Qdxa!Tfw%Fl^Uz~=-*dk>#VzOE+ZA}Sa4nwzn*>x1WbQPn=RXF|uX16|lj{Fd) zj-6Kfic7+EmU{-$bprOrT2Pusi)GvzjIw0y+9Smob(MsR`Nc`!=Ta>Q@;5b`PJb|ss!xZ{3Q$MLjSw(j(O)&2 zQn@{Vz^};USI13KKStb9wOdkY`Ha32RL_E4PoO#ogMi`;bmxE0FFq=1pCpj!thUcJ z2z;Bj>K~mCh#aje-cfI`J;JZETnaR%Q!`pDMq9wFCh2Ed={&CC#|FR>xC5+-JCz$# z+gziAvVAO9n%!kbn%@qqmG?F!`f@vQp}WjIE||B|uX-YU#+{BO+wA1hXx4gtj)sCS zzE0Z*mMd6ceau+zzw~vhbR5;9*KI^Ut|#d@w1|k(yC`R_M{P`!X?TIEFVwljEF@S| z+Iyhh61E3QeV_&O|N4NvuGgj2SejtT?}Mj#HKDusbn=yO9ipc{XQ*GkVn=XkK75h( zFRs-l2x&@7{g*qFr7257J*57xsxeJ%_5X>E7l!|<(_3j+` zkTzCht)3wnlPCK6Ut^2<5unY29q50^+{%6E)T8BhPu_m1V#=CCvO?i>!g#`vpUJ&Kc8h9*LC=H8xBPTD zvp*33>vPu_h3JXA1O%CH4^^ak?-cTY+nt3WD?vk?w&a(-ifHsai>kqeLpK9shE7x8 zJ=|r5R|5zMS2s`AKk%jh(vmmxnj$$7j)=FK@Vqr^fxKMl{@B|t=_Q|ZyFun3efiU` zZj2u8DTptx?bIK$}|mX#2d{Bzjy zQdC4QB54R$bycJ4Rb7S6<>-kn#HBuSPT~vH##tZl)TW3Bnxi7s+sdtj_S5i$+aM28(Cc&J05@WwVZZ1@0j) zXaW}y3#ZgUxBdt(jC_ydg0-_7aN!t|bw`o*O^DoP2stt~F*v&_qv8#12#KNPCn4?z z&aRVd2oLZgL6a4F9G=-yUQ53>qyQ=&BR>p3V57d*_tPfDMie*S#_lefOTb z<|p$?>dM5g9nwOpnH_(#bwlacjI+IQhNn-29?Cxjf);|bbQXEh1k20cpSR#cmPqRG zGI~*cVsh|Y`}w&%G<5ORSNC6o+&2KkqH7t*xq4wD&mC_@UiO|D4Q)cEAm%^#-i7gB zdA=e%VEyhvzq~omL~(H6L7Jv*Y5_dv!))~zaD$0Fax!cjAJSNhwtlZTewXSI zXOxVAdib(RJ>=?V{7K62?&Mjz&a}v5z0nNJyalgcT~n-{xKPCCYRzE*c#j6c0v8IejE`k*S z2$mRPMAs1;a%>WgwZyG1sx?74uOM3GNr}4iH44)m3BlB+6(af=5twv4VcYrv!;+0T z*Ud&yN1BQ;TQQtLtg<2SZyBj8?W8y_1L%N5rYo=szz7lQJO-0$c%!6;F(32!s${1R5cKs?Yc!7pNdM+=)GFCx?!Gw2i?&Ez# zBi$O&Deu1VTI~;vljh||XFQwr({GQ>+=mAPp+d`l{8IUM_C7c`WDdzmt@u{sFS25y zhwHru)B)~)w|7RKt%nXF$717U7xo;{Kdd=SqZ{-fwf#2cUk-_1(<5QeEh*>sDk9ZA zg!~-7k`Pt%h{FTxJ!zqsSZlIa}bET|qtcbosF{_2g)ae0I+U22M#A zIggyKqt>Rmv)Epf17OBBb}oknwxC<_=|lajfi>{37wO+cTC_vozDMf&v6dJ;hHcSn z&7%2fYXAs6l`ZdgnIcIDr#`I3;^tH~yu%T?*)Wubt+{tZqEmamP-scK>)>fXa9mf_ zR{dyWYo^CH<7gGk6d&rH5W~eQxA-aIg**!$2!MM=uQ z2Jl~EO~KhoQAVp`Q;xzU!I045{}?ls$vCl978W$jVg8l+W2EMf@<#?M14iYTUdR5! zdOjWjfJB~Z{j%`xGV6|siD**d8-Ft5%OUK-D|Y8c$}^`b9tZLf3{?o(RoOg)>zPcdq-cVcBr$# zij>^|eNFQB8Jr~_o!q>E{t^v5V_9DN5OdEzMbz_O$j^xPZcV8fX-Vrf!nKVN zW%Hhmv2HA-9+vB=aY3;)HqiazswLhbaSGWGC@ARgNv-wzsk{pb{g41nD3A~UHd=CG zF{QPRC0uFV29_U62|7#fPra3zTF;i#4h;PDe4}?S2TxzrR+IAZh!Y((PU59Vq2$9HT~m% z2;5q1a24BZ#gr=*Wr0YXw#H~}8Sl+l!AZ~$YgOy+MzeEBgm>#x$WvM$YN{Y7W^So6M?MWv&j zk7b3|D!H7psoZNNmLbGtyb`eHB9}eVy3H2T!29_a;BM)d+;qJN>U_H4@~P+tvxn4D zAoV9ys9d(^^Hs2t^|1!#qv~I8-QUtC5bqOu!XG;@zZA@6JRtpXSax)FmgfN^8&{w% zS~jbWz1!O;9eKoPCqGRPvWz{2ASvJBbZ^rG{o$p{mWpClyy8@+>A3)sPo`QoBtCp7 zVqogu&iUr4B&|-f1SjFL*ykvd%8ff+l?&pEvyjo_#a7m)ZcVPk%r(lQNxVm;qUi1gotxaQf1x>fsOX-8cx0Wdr8mX zGj%_-Yo}wP7?%2LCktx& zVRLu{v=d;edDxdV_HUTSKJ5pZWemZvnwn_&Nb)$x`bpf{f2YW{&`gu5>yQKc!R17B zv)Z2r=osUQ-=M60o|1u$A@JaW>S_ecG>>RsHCQj|YBZeM&8g|k9^{UjngUv|tXSjR z&n>87J2i4UxLRwkN5|RR)YZYgw?X#U$ClssrS}7L+86i0vpjRK&<8gcj$pL?Q0n%l z6LwvqsM2^6H*#I1-bR3v3m<|1T_;Ba4`=#xeUf4Cd47S(!LlDNw3tG7S|_MtD47N*8;j;-d=L@0%<3r2{6$5Z||m4 z>D~_kH7h-*UIF9mmnI$4>qmlA{lC46YS!1S)dCm#V}Bc2X+!i4)TAa8<6YBE+ALf; zV%xfM`_gUAFFuzG@P)kXJiD-DBaUFDkJ%r=lbYyLWH&z!?-r}BQ{M$Jr0WQJ%ZRyV z>6tR8>hNzIeD-T9bNxAW1@9bZwW##XtqLtnK-*wC1~?6#^|zb#&||Q{^16QiJCwki z^6v@e=<281JTT*Nemb#U;prB9MkTd7uO%+J zu9p?)d%WV;gg4?Ou74T;U}lacCIUZjXYVY$f?;!aPLttD_Np`O(D;L`XU=Hn?sExq zrUl=-hx^XSUX-&xXp;sT>?iH^#MgpmPvHP!fN6{^@YEgka2gGbtZS3jszM{*#FiX* zAoRhFQK+Koh12Dc(1@%jtR;spb)^N`gxu=$pT=?WwuMl)!T43v^?2NkzDX$lN?Gdy zuKoXb75XT46>e2A%h&*t#)~>UZx~Fv;fVq17=^fI$gFQP8 zQ|~$tD#OQnByJKm6i@4P{3fAA{5P?``i+!Lp5?py_UqIIH@gVm1w&B?JxYh{F7lbt z^v^YeM6y?WpPzd#PqXM?d#l+HXBd}~rmkY{;rO{;(73C+&&9?UZl30xx)LULQ9#aY zfiH{6vFc6>L$6bF{xPE|dY?&p(v8i>Fm2jpybaM_)zHI9xG~)EZ)^uam-@c}( zZua+?XQ^h`pUe47azU37mO*+sdm9~4Zoun@a@nO(hJ%SO*tJAuUgJ2mIg*>2u2}TG z>7xzK1|*cIXF&szU$~otAI8}5qq4)3s~)Ih5jb5bRb#(g-pY&+2w=(|1%Ys&T12on zejLX>oBEbr48~}$oiw;DFq`l%aaa^qnCN+Dma&Ij+Ng3%CUUn% zZu9V0TFFIwT-5sqb)CxRtS)IJM^}BcA&d6#*9==@Ho|@xR8J}B6|Si5@?9&OZB5&K zd84A#WeRomN%bt5GTeml$%flFC7{@%fqUHUlz9oOi~tQg-nk?9vYs4)?oAq^0tFCc z$1zJ4Ue_0;x;e0^1H%K4SWK@m?4qzLe?Om4lAEim>zk9E>oMHT=?vnF=M;R5z$TUk z34>c`PqGm#(jrGhlSpVUInUJvVK^U6DkLDv7W8R9B83DFP}AEnNN0O!mP|;f`bEYS z(=+7#Oclg~gpaF#%(*+7?&4ueK@K4B&Z-C0v%D;&{>tM9&h}hhON4CFQC7)OR2sS zd$Eb>V_==Il32|l2X2ajKpiUcj%4HC#sSO62duF(oq)F)!4|;_zq@N&?h7V0TrU`r z(U3fg;HsDIx;t}d;@ypI71bPi?B{P$arzCo`-nX*!>fw1K|>ZN`i}^%j+XwyUWZPr zn+IoL?*ZI{1(UCH>8kh@NIXI6ljJ%z2(93)_|eB#*%jSIoZ4QF0;iF0)#GUU5-Rkq zz)$^iW1{nO^1Hm!UZ;mNvm!9%;-~HkIVfs#{HSpKEyL#daY=~@p#U7FMg;{gMylVl zx>4C44S1{X`v?Ia{&zGo=%et7!+0j5>7oN__^RUcENMW~Owlm!FAE1dvvF3S#p=7W zY(|i+MHoX-opg0sbVaBUA?E=kp%K}3y0i5u zT$1R)Xs+*5V9c`dB_1x>$SB$wQ9Rk8|@=Vo2gv*Q3>Xy0brV&r)8tPmx!%@JKb z7K{?Z5Omsv<Q#}+#|U?vCG)74Sh?WNef@aNDTEvH$SJ{4j~TjV`$6I=Ks7$sU_S9S zd+kF{2L)A>$=d9J)!VbQ722g{0nE?1WZ^`ID=5OYIk))CW;6<3C8bkrG`pRMGfs~F zM)OeIR;YX|d479DVRg#S`_&)EOjhJeh_t=BmZ?GrY1&eq<5Dw+3dBv@4SK`YcTWcn z)0Y~Wfq+LfqY)F2$JXQrOTO-d!Pl&*el%l4Rp#5Q8B;M-T9#A@kcJM}^2oj$$pq4EAqkL$jRtLXS$#`3(*_ZCyc zty1H?^NQ6woDoH1W~>jG#H&If<6ziaZ5|p(LCkJ#40m7h2wdu*r_&jnWCVnT>ZM<` z-G&I~+`>Z+PgFd#oA@!1_R#!F8c0qmPuj}SuGH>1Bw<)kS>ahul{9b4L>4zI%x! z*Ta}ojs6B#4@TTHwMb2MQjzU#&j+;OgScDx+KvOI|A)ck!tWhDmD}3hNbV=OWpXyF z(7RL;i)7o){2V~e{d?o#^+I~a-sTTT&8ExM&NQ?^1}N6!7lSKi5H~-UV_eu?nA;|xUr#Uw|P4EH+rsaE?T}TFWAu@SHdZM2keIEoTn>btK$Tt0V?^8&r9zZv}-4z zsP{^wy2hG<3gyHh!c5=I5bHV)?g0?k#@o^)p%3z+J@S4g_=t?{!EoK?YNmD`FdaXa z5EL_EhtSk|VmAzu-gKgr%f^PNLMleXRkandptG%4{CURIu7NRVkN@Z=F;2biFztHK zn#bMJZ>BFnS3Yw54PJOs^AUa(H~N`pA$IcV=Y3^-N`+`_YQaXoDU!i=#{s{^p&7xu zfO@0jd=7lRO!Af<{{e;uguOKRm$l6*tvEO*>-4^zTj=JOvD!b?_1nQuDO@Cd*%}Wv z(V7$6tGRs1vp9ILGowo2w!+C$EfEUkt$#*IgNze~L`E2_6fUuTt6?z&9nDw5hzye` zV5}=%09qU&jkx`-$ad#9%oL-=X)SVVN0VFg{6{1OQ*e|BPhOR;}2{A4pNOz*AJfIAV()++{KyZ*YB^rqK-KY-0Z)#sIPD z5=#5?sH{Xst*$3IkJxFrguCFfTm^yd^*V~n_Iq<%6X~QFrf4?M5LJBUat!KA#d8Os z_08UxhYtn26Mml8N*(`$-*iP!&l|3`XJ{26Jo6C*xlw~~Rx@eez!lH5^n^O1DGfIZ z;-)#5^PQChNO1^op$P<#O+ef%?*677%Y}^#4Mrr6mbzerRjtm&MKs2&)5?2g(a~}y zp{-=qeEyKOFtJ`VIqx$a{r2N8jyC!c-*rlKB zGmJZV=>5?KAa>1*%~V8X81@a}pYS>3H`kT_z3$|JMLY#}%|w-mdxc)5pDwzdZ%|0S zgk^A=!(szf>#HKGrHd0doXI`}d&{d&c8E~AXe0vqsp?xfRzoaMTIcJlij^@{nNQ%JHkbaOu-zeIc zvU94X$F^a16*~qP7j1U-F&!${JEFR5w{d}Wve1J2_o^6JIN9d1jk_oPB6 z=JBxtsQn!AW_*u34+G(S91Xv(m%exXOn~%lo{Q;S{SkQ3A;ECcrxAax0)>G6G&qLo z=Jk$qCJ#g~-UE47!Q4Ei@@xkLC?@`~n^xb}3;1EZPBPW_z$xV`85MZIjfygN-l1Tp ztIcg(d8SjijTOv@GNyf+L*l4Qr;;vm9zdG?_h$$5hM0RH>q`&^z@9uopwjwJc5?y* zE+<$r7#OGJ-VA~hb#L7y#P>I-o;QU3@m**DA|`)>eY7S$;Cf}Ap}?UYbM}cUgYN|u zyqy!QfgZ&o>*y_Dg@@~zw%-ImPL-}7`u(t{W*t$*`(IkIh%x;NiH$0MJv&-1SH0av z0{S$U@EtjGbnLrXi_x8i2ixcN3iOs8BTdi^5Y;{{8SGA8_ca~Qg#aP-Yh-6roa`9a zzBwlE9^`a_iIBpBkJr)Z{}%wVKuy1#F&3>NgVEr#?MSzIOxKhO;zTmQ$4Z2vfUDW#tajdmKF)hKldq;St#hBWEnaq9uzkA-X>rH)@IXqVCldjg645 zvM>dA#kKUCgDX)_+|#)PHpSut`Zm+8dk>MC@))x|kvlDkHhGnyofR5f*fa^=qHP$_ zeFfsHOrWPy`wE}87t8UC#Jt0gEr+OT?&Gsy4{_ab$H$Jo7=1L=qFqv+cnO{B2g4<# z753iG)qQ6O>HORFLaYDJ-g$6Ev21Plr`)6D8FJ1^Fkr@D6GtNSv8o=xWBysMM**fv`)Etu7m|>lF5>v0BDE4m{kx6Xg>1Q$lNC&Qa*pfiq z3OnM9dtVhb=V7CxF4PQ3@cN)ouH#xNUt;qVYvQy_u;bph-oHhc$_fMm`7KVWj7-(* ztcZ~3yO<%CFhDC;D;knX!2dc^S< zE(wu4Y@=u2_p`PZ)H{^c9+LuiatpSbe*vCR=!+9 zBWSI{rCoENMUzFAghw@E{3!i^TE)GH-QRF#rWq5MZjJpc{~jAsk&ZWI7e0NLKf;-= z#Jb=ykt7?YIQ&=|)O3BuzOiJ2H=csv9GMl7go@lyj58)l$k@Xu>}k@LN0`kgiO0g@ zD6N-|Mmp=i;Pm=wuphGlDGH|<_E2V102Koa+u zgEL5yBe9d=66vb*Z)1uX9~xE*aZ~mQkXV2sGKo5w=tIYPDsJXUwpoZ;s&H`wNqp6f zu{*JEMO*K$bvB|wWeZ#4hqe(EKs_vC)N*|qoIk{#d6*cLEb(vU2Y-}Xo5nCc& z%1vnE?iQq6+6@yv2@aiCAmt0oX|0NgD&fCqv}*B7M(FuV%LEIK-XuZ4Y-@^D-Tu@iT7aU(chWG|kEcebplgzJmeJ8=qR5CGZTVhLK8mPwGFaHIRPAu%e z>pS~lP9{+uw_|dN(r}A(^l7Al%L+XBB)|Qr>suODIvX)Q$0JJS={NC-k1xz4L9_uz zZ3;)VOgoD@>q&sMf|>m77Wv^zPvfW(>BQa0zk41Q`ove+%)qr5ee(+Qc1Ia}SC4@P zNs?Xn-3N0Bs-PTR^Ie&M>~FdD?;=V4LXvb@V9K;vjOaJl`*ojPiaU^gWgiTQeK@U< zf6ye47}c?Rpry{ksC8bbkeLsJ-(oPHr-yOm?^~Oiz|3toUdu$?ZSUgXW@7@r?R3O` zkSI~?;?ZEnrc{iePi}cmIGpfBZj6aqig9iU3BuG3F!!+X{K&>o=PYqYIeeClApxZc z7N59{I=Y>*2VpHjJxFq(u8*l_)8%|^j`uv~##d{ZPeADND!F@Ue{>WYYC6nA({HjF+JFd9S@;RHig8a%A&$--MSO}GHuxo@ zwdYStgK|2aN9ws?|K0a;{VQzyj-Xw$nHACwOM=i-hO*&n2Jt5b_=`USx4$WPdT~1* zAD_cK44$?p47L3vi)}SU*yc{fm&v%nvP7z@u|ckW)-Q{6bX@R9*)OU6w6$#m?UcPv0LQcHYQ0|ei2_5K z*Sf25Wt|)8Q(a;QC-L!zyiiv5JQ!|fdW;>BYlKbe+I8Wx}zPE;5q$6|egJaX6L2S!p+J3zFE-%!Gh_6*F3()qxm^uAh z?p5-4?jM*$`jm$;$DYXf)1l^T{UPdV$G2}4O51B2O4G5}$&lQ?GXmfD*@r5TF8|6( za_{<>PWngD)6-RU4YK7k$UP=W+sbm$MHI@N@GeBvt2>zCU`l+sJ&xYW8({H*80Be~ zVP~QX)6rXyU8gu`$y`VLZ(pn=?{mg_c;icxTn!2oK0F6!BVFQi9O0S!Yd%NRT8uOE zT}UixfN|?QQKWF%yO_+Of}X#_BGUEH_3=K&k@$*dJsEf3%l-`^iXt|Wn2nE7YrRk| zdr_c&vW#4_E2AZN-29@{H+^*A@v)iYb4FNn{5G=_LIUw|DM!KAGR2hXvtUfx#c@qI zYI?s$gyM$*u%RUj+;-rlG%lC5)v+Fz@uhdNrgY=e&E3#vY;}vgt*X*Ver9~h#sm6e zNc{LoZj1@rYjFARdBhKE!+voPzA2i_zmQ=U>BKELSZP5f60^C8Es{(m;^z0*O9lzu z&f}jd5fbW3QZd&?7wV%A;8RbZw&h}tC7B2<79ydz=k!2gXpvjU;Lyd`jlQU8Q3$kz zJ%z4yB%P`phxMV#$W*<~iU@E0myiKtfT`Pp@Ks^kiy~bls9HSAig}V3^rT!#6l*Ha z!U`8dl3Y*3b(w^@XCVE>n~bZ4N>eTwwB85?QK@q(!a4OY%66}Rqg zEN7C*5gN9*kR$n9;^s8W(c{5t!BG^|DcrkAfV%HhsIG5SdC$Kt(hELLb^z?l_z<-o+fozT^?3AZ9mAG5XdX`{IV@SVra@b=$>A zERsKYmi|s%?oEugFq9<2`O=2~DnvRFNj&_vPGf@c8OI*WJ!S5p_Co?@+34XfgPFMV zmA0V(`GG6Q7|_Rzz1L7HH7uqC#B@h!TTX(*nB9lc`b8nIv;?YQ=J%5=2}*dST2wXjy$>3(pkkL z3}$Thh!MnZEDvQl#a)c=aeJUC6@U~2Q)PxaXNggh9mVvio&$*&ij)(ds=Nre8R}r9 z-A=si{j-A;=~VHfBndOkNql3y18-$+O;vlczAh}ofiYAdn1aCc5+xryIK*hl@rDH% z2fAZ7;5o|)t>Vrqgl(M2Y;#_8f&}+68P(85I#mde>$Q-Hi{>1l%5leP{toz~!)bmu|W=Oj$AGlRCp zB!oU!-Wn-~xC^gto`RV!4@SfXq<>SIH00>>r8;n4>5tE>V{u@SPTklAzA0ane3OVI zRKdIFkU2^R6ZRzb-t$oAwYE=*aJMt}PYd-Q;+#DmZ$*KA9CR4L=5hU$O%L^?IcZFr>= z2ojH2906xdZD>u{j`!8FNpk-p-R;wB7?Ezu{y2PRmf-y!etb;8JVzs_SuMe(=wOUD z(}V8B{dg^_s0o@8d%**I;-4Ir`YG%VkuD&r+6g9@gaAbe3P0E15F1d~Ik@G4A}j@r>ZVE3vO0p(>H?ZRjd!5j&rB zFb=&NPE;VOeT2EBtwx&8Kt!HIjFv;(h8&-jOpNWa&I{j~`-@A(`1bfR>`iDL!?j2$ zReb2Bh>XuMQd~hSfj+eJ*+N&e>R|ltgkJmjd7mRDaYZ;2SLj%}1Z4}FsFPV72 zVZ~W|VT%V6d|+)Ti3f6iA(!}57 zt3!f}Ey$65ysd}|BUAah3OY;3;4KTfWMRawv6@+BsI4aU~)i3L^U`)MVAb;sfhR_b*7?f1TP~4K*I#9&fk}N zn$Ux&HXEy4tcOXY13ul_3?6ZQ6HaIJ@8l@`iFEWy;7R%XV(B|G?BT#O7qa=$cjT168lA#;y6jx8his128`cUc1eo^j+t>}Qxn zI?)#DfrS9o z^efVl3E;}gvBdsOvHC)S{FcC8I`Q~86+=mIE+eL>_6kbBjJ=J;*$!8#kmEYhj*&0%Kk01F%PV{H#( zQ*&r)@nJl66|SazK&wK*@L?C})Gcu~o82~QKR;lElD~A9B2BvFq5atz=#l`+bjD6R zdHqSLEel^nV}_kMMi@E6JE>F>z^V}G+P;%(m~70{ce|L&ehq!&P9K|N=yoF-tcAj! zBqc_(4xqSJ68tns=RUJ>yO3K<0ybA0@NC=>BV9E^w^Z+#X3nhii7IC`*gnJ!@R?^! z5&<{(y_5z+-DS8i&y`7}{Zk9^_{1!X(6+?R%Mt+{f+k7wroodtEuB?7!eGXxRE!{g zdHL1%if=LqUimQdxYgp5_@c0)S&1RfSzUM$A$fpdG3fx(J}Qr&yyy2Z*Phs<)+9uh z_lXa`BGNT{eSyWac)RIFb#xm$y`S0Ge<=Z9 z*e5RfWD>VdtRyj#4igVF4$y6VzSIQ!lVuMVkY!+zPS0T;9+oQ2wv7MGxJv8clCeJd z7(Pmy_2=g`F-nsInH3-Ujth|dg{9%e?RXTl8wT2g6X`@P)$rUfi5cfh{4!C*avhI? zJ)lS0lD{&7S?MPDk%RSPY%tQ$85a|a`eGts6@pi~F^5O>t;89s|LGCw$e7$7%U&C| z;|b=IIZ4gM1Fw4@#-M+Zj$Caq=`VW|eW>Z#V&;aENGg!oRy~Z+y2{a~XlWP{%Nvu~kJLTHlscKo5V-qnA@Ez-36}XUV(^wEJvubdN`PvW>x78qE`*f%gAGA59Z%d#mk*?-LEM{2gVI;|m zCayS$*ql<82NN}?9b?+cWX?gEopAC9O0HuRZ39;iyppv~76i4gv2C&)iGB59GkF!f zZofc1tI<>Xe2%`ud=);YXXkkXTtN|h7P(@i z(I|K&6*FUzE}rn(IGM?z_db+tb*0GKDr?B8%2rkABtJF26ygEpcuyuC7$qAI{CoKg zA|3ECdOP$;kZ!Xh4qap-&kR4zOlB_YqfpypDZ&=SUEBy=5X?^&|%p^#7xajkl|m29nm&F!qUD^aE$zB?BqEXCMiRaAdMm|A{(1 zFrnX*@)Qw6&{~Ig&*O1o=Q@mcafGg(0o1iY z+c#kklStR~9E)`aVXt(n(cY_lB3EY(9{BEri=7QqJedZxQ`eus-DmIcy-D^Yu)zoc zs_9pxBS}!N{Y#6aDnkN+>l>LMdDJ>@R18#|^e03*`}qNoiN$;A{85U1bLlpI*~1ou zNIp!1SeCX`_!;T49(e-gRMZLVp&bOWC&w54%XGIbQzS zWN4~*iEODgC)ePKKJOn}t z9h*VBW-(86XOOYUT&eIg{&vyB2(x*JqpQei5*tR6SRM}(4?aSxsF8W{IYQSKhofFG zTSdNqios-K9cWHDFIQiN9lCOPIrCI}j{hsBAKE`AV3MsVj9f`TQzb-4t~X3Ib>X%p z5LKFmJ%H%R~#RNNGIw<)#uj;_d1OE(nXcwiE9sD>d^)SGD=8Vk^eA1or>Us@K}9{hxLWo+$>>m(>Q zfZ?c>c*@NE5=1{T@pboY`+YAd8$YRXnYxmw`n(W7iFMp}b@k)bGBJ^JETlFmc_C zY-YdpQ06s3@5!xZV|U>-%jz=vnb)^Y!%TN@2S;?(m16&VJ7%mZey`$>#=J|AJOb-L z`l-1jsU9{R|6qB<(YJkq*>A zP;mD=+-Z!?*JJ9IInLdV(1dJ!t!)`ZtUq{~-^XK`y(yR#vMbJ_OkqV#XPq?WQ8&Qh zCv5Ww7$4TthK{SQ;=9zuhF7E`vBC8XgOB$JqOKN{yiJGSk?oi^el&~>jG&<{&3B~! zUt>PINcU6uTEB?VR`(T8?q0#Rl?&ixYYiQ~KGd}6HSkHEOk;E^(pC4`kSdXm&SwP= zLojE&3-ok}U1%D?Vdf?T#%AG5O%nvYHK9Z(3HM;y%4NMbDp|)NdUFJ7hY4(Us6a<~ z2KFtP%p8z3l8WLs?%3;h2l*w{Xp>tqEAMl%6zi=pP@i-0*L{x6_;p$p4i#ZuA43-D zNDNRIi)m)Wew=nAzh?sM_=KG<lfmIe=EO5q^tQ9 z3wKiz4B78Oe&Y{Z**gy!>c-f1PqN}bf(WH4fsBz^OgV`BA0;?5XB0_b6pAB~ApYjQcp6&v&R1LZ1#Eq z!eSho;xuTHPS8+{(1R;s%-4m?9TG%&^`^xkd<`;1hkMBHhX7a`z$XY(`1` z3*3zi#pxsauyWxnSQ;6@V(bDO4bN2GYAR(oL^_rIC8qtQN0JzG%-DX6rT=?=d=M1# z6b&6Bh}*s+VEZ&CD4*l^mbHDEzc!0M+y_(n)*;bCMCU zf7XPbjIgq(%}mjBr?sB=HsGW(c#+OwiSh}d6jdT!;{LHr0ekz!2Uyy-@5eLjKB}4r zXeVJA&dqdTPE8tU8*gj%Cv2prjcQK7b(zB-RMn&tw%0OGymd%|JH{gfB`ha<{SJ{% z_FxKGnk(|LdbI4}6(W4TwvGuDjK(g+;ePz=MgJ?v_$b{9szRh|_?m?!^l%LAS%_6p zzp`hWDi!IbU3e+AZ6O{Wrgb%~u`~WNM9lb|0S!%ioX@5wu95)c;bG`$7+}V}aHe4S z`{-@ZC&|>bi4jgj`0Cm)bX|#K z7nS?E_s2U7FMFR34j|HzME6~sH{2aep=IoZIcs*|=vi<01Ozkl-NiHe;6zWK|3#6G zLhAv2dHWQh-Y2kP_9W=?$lPe)fCW2!kn>HsKA9Zk{$qDaM7kv>V^oWD?}A}T5?<{| zJMgjRWOEEnq!YB&BKrJBnCt10L~9CG?>vGtUjEWpAQ(qixsl{ig-EAug@g4IcF+fMBp&c(j|XJw^QDt@uEgns5*-Y{fUBNsgF%NBjLmYX^HjfaP7=9~L ztIctFXm35%fknCwTw16Gt`7`EX|k3^a7%b}}6zy2#0M zBbmo^vHbE!rvHRxx8cU;!)D1TX4QPf>nm_2KGkS>nA|uTvaPb}BtJL4ekmUKm-1Ug zx{lf+?4D)KoY3d&mnhvn-#jt=ivXkDL7GKV~Ol>)bKSX>Im14+K2*G#OK_bJ?h;)i4gi2u->BPwLn!`+P<4-+PVZT2Cnfxx(W#mlZ_7oNO zE}`*jHnURfFP(XKAUk`j5b5Z(HoS|&G+R@s8H~cIn72w|%CL)c8aD9CQ|e)cy3#bv zr8cTP4~fk6i&3Arj}gq6EtX1q<5i%d?^;QcvvC3yF`W>osH{j4lECXM35e;qTNz&3 z+kd5xPs54?p!Rzc(O8uakMTy#7WkfR8RC{qEHmLj&wUR*)->VPW_yxEje}2aMepD4 zkJjpU*f`Oc%%@9{Qd5WU!;7J%X@dPJJ=@s?NI5zQBeh*|?s+-BKKF#3HVFoI-Isf6 zs!BW>cx=jubWessq#Np-MGp@M`J37PW;@GE?nLnZ>0hK1e9OSnvDO&Dx5oCsR8%*$ z%d9j2>dT&DzU{AWca=vh>_*d%D&#+o#EP+I7@#&J* z1YylX%qH;@Z~Ru|Rkffz%MXq`9t@XUmOm(Ckcb4GXsE5ktK@4~FxHA$Z8UL9B&wx- zQ(u}sfJi4q{B9Q}L1q={)J?GdR*}r>G7wuoG>)zS9u3c2c5y*0=s^AVugJLThe;&y z8=+-}mFHraav$QZI$S%rh=~W>4m|4J9;k}p66p*a7b2Byi8%2nRKPOf>%hY8016ld zXejfVNLrVW#K|seknvr~NI<_U=Z56F1|!l5=?dbNuFO2Y=}w=JF(9Hw1TV8BKG_xL zGe|tu`W=4Tra?{J5L@q5^ua)ZwQ+zaC-F^rv2C7tWa^EeZq(9PU!(mkcf1S{fTs#PQxkBBeIy@m1CM$j;E#f7*6G_-Zf9X?p`IuK5DtLN_&>BP-Q zI6xl&>cVyP8NB&gkB)9(ZyVB>-*@v==0U;WMLH>B5jt8L@U7@2{CCfTz7`MK77K7s zS}VEf;X$T6f(6Urdp(G%?!rxr*r&rPWPa@{(KIk5AM0#IP5Ecs4?0ME3*DOSh{KWD z(s@BmF8Wfhtfwy-dWJMi?%ei-WTh?C7`z3<;E(utAnM+0mg?6=2YP-ZM{ zLvp|lGLdS-YWit>VtEYOUh@&#r&%&jjAcbS=1KA#8)6IQIGM>3&eK!6wv8gmhT_2y z?YOqq1|!r>u{`vZvJm!@w2}#IiY?tLG9HnyBz5YT0B#ix;Pi3e5la!beke^l2@4Gp zaIU+_(#`_B^jSgzYT_$4`6<~9MbuG(vvWt`FR75H_ykx9Q6&NvQ<2UJd%{>&sHqTo zWMLQS=)IhVm9`EvCLTvI%UJz61bF33@0}0Vb)G0|Qq^-Je0+Eu)^sJwlymaib5)9T zln%V`UCq=sGn=^=Z>r?S@34z>#GaR3{>akDK*{4Xup!s4G4?2mrPnC@dIxSKfm5?x zf~3L$5||!T^c}6IbJ1fWlSnNIEKm|8DUDyVu)@_4e`z@*Fu(6A8_J$xk-aWPx*kTx zt5CS|_272$iOl%w!p+SN7^&}$@K>qWM3OG8F&pu^lKsJfB6Ky4Ki%H0VTye*`Petl z4kOJs;Dt(W;Ni{OfRIRuK%JNGHpWq6e61 zNfL2m59FvC!cv-^oPs`mU@`q~y!+15SE3dq9i;U)_29lc6g4vQ$52GNkC7WC^@=t` zE00SuS0x>Am(=J~;@ z8bZs$4Oe>)z3D?NXu_Q{Yr)ePTunN61R&h%C9hWT^hVd|6k02@WlCX12txk1FfEu8RE27^O{XW*uj(?2F@SrIS?vzXEqovhog#rPPz2S&epC0*Ai9Hgtu zRXiB(sxQT%g-(nB`qLtv40MGTts6+izD;)|rItjM@t8vHec9x5HcMG>GZdMERs zz+&Msl&UT;HvnD8^`Zwx$<~JM^En#C=LA2ctIp(M>KGne#5{O%_2?^~_47Wbr{*cG zf42}xCGsVCl=u~qj?ncb4f97?kho$dZl^xL0%uF;Si2+o&EQ4O4yLxXHAe7e;o2*@ zLqZq2^Dm*U_rk@7Hqh9MsK_widq zI;tFJMI2@7(CLktOeQ=FY`Dc%WLk{MxV_+M8p6uX3EDb5OgW-lXQbovJxnv>L(OCY zj^4~+6*z=#KTxf7QiuX7j7;V0tccO_y%P0`o|7VKM${G;jL@*dffPvsBkrugm5ozK zf^LBEYtAB1A-QEfBkV-=_eQyaKeT})Mq{=wtckPIvYUr%S@Zw~nP2H{LF%R5Fyhmd zQiBocWKMfr;Dr*|5hd*wTRY_lR`jrW~xb*Mp|rLfkEo@0TARlQ7@W5E@R~ z@scjFB}qa?zz&QV=Z;giUdkW$PGL>~-VS{de9iLCW4-U+E7FmK?R_j}+R{@B-EiXG z8#J;6j@|8zXl(A8Fhr=#jKFAf185qL!G-(p(J6Od-FSWHDD0S;hw>sFbK)vfIGG3c zgZEJ@t2BrOsLl;%YGKhDc=9402~v~K!9kw~P5b4zo>i{!of0~lYths$`7N+(jVV)1K>ykIQ8+zP|PwPc7J_< zxt4sWTe!nFRi^keB;zbU-V2V#2IP9C<3{fHfo)dYfT&I5@t1}%mY%+=+-E9;9$8e0 zbgk8S*fQ0I_zMfH_DM#i;>pznVOJX(8V6TYp5FUBD_x8<9F3hpDawL}s0)>O_pxrg z8Aj;XVN29o`M{)Kk&e9G@)5^o+hT;41r}ULm0R^R{34w;%%<+ei*NGDkD%@yPA;&= z2z3+8_L14Y$G)qw-L#O z_44iCU5B`{%VD6&gV{oVl*@D*J$3p7tpTSq75h)F22XDse6!?N$rU9WfT@-#Rvz9B zJ573OxgY6!Hjl)Ds2yp+dtgZXn9HWKm}#vC$KChkR@pXJyu|7;#?YL!4}0gDK+Spy zl1t@-CspFnz+-c`MY^HSS=}{x*fq@oBec!1=+Je1ZB|YgXnmpTdU*lsefT;OZ1Z5Z zHAW%U5aUPo4LDmEK-+Q>B45cD+ZQyIB4qm%k|6wIO*(NqYAWi`#WJXCKRv{3x?09+ zJKie019{pDaoF7gBlwQk8kQ;lM4tHgikG)Blb)Dq;EXe|@8rbhp@?*aF*}$-`}zw4 zQ7h+I1*m)zi-pb>s%z5yLQr1$@*2h&>0#8a3-GWfiHX~0RM>`s+XrE$N$VS_66v%V5hXIeUT*FY zwpJo~|6Jw)k`rn3uT7G1A!=_%MLk=cPZ4S#pJB!kt@+h+B4N#|XiTy)W@;E7zLAaA zZu#~SiMr9*B{7`g66yYNb zzsPsZJ9r({@^v)OSn?F>CRsD>I~b9U_HXf{U^tQf)i#?1&xCxmD;=FHMtfr;nxyU3 zLvuwA){Hg5NSiH)jy(ZOZ39d^nWhxi4b}e)S)>zH;K+Cq^Yg~wd)CqJhg=QE?Vnva3UQ& z_``Rx9kE9fOgj*Xa>b=fWX!a6&~Fj+)(|&RI+R9?*79uZpDD4y-zw5|7vqGx6N#my zyT{~wZ3kZ8I|mnI-k@vJb<|g*fw@0<1kK-Y!DABfNe0+;UwXQ(xCu9RPJkwfcc&eS zMzvf3AV$-d=h!xtw55&()9)-pTaM1=2K;DM9vAdJUis0L*RrR9%lI5kW6Wjo0~If$ zF~`Z6#L_l6@UZW}!BBh--BP?}9Qhp{`JHpIs8v|pAr=YI)g_H1e^I2PD|8d~&tl5| zEL*t-W`??OTz&~(l>;o5P=S(JNlol{+419W;F)7}M82F4sV=yMiN<``Z#jnL<~$fq z--{15^8RPYwyH`e8T9y~|NixO;NQmY5$Q-n*XQ2EC`&^!f%7oZd>-OdtZooDKE`}Q z((%Oj|D$1!W9clx7fG&iZ|sISj|UAyd$_MYh~S&?NPhGP_wPo-^VlBDm^1-XXUb7?$G}1u7&+NDWA4VI2)muY z*m2yQNL)C)1JkF^LqJktZ@-8;zvJevX&6D0Nn5#w8;qH3fGf!*F!RT1=g_1ToU5K=dTFKw|qdR_}#>L>$s{@{phZ z25I+iVB6x!(9+_;cK!u??ooAQjt1x3&BU4O50RCXiPYp&l(DT|kfE{s1(rMOlS$eX zGj|5!VMY#~K1#%+f;wrC(T%+5gRtWBp=05O^(R7+_?X;xdNP7f?|`e7DRVf@67LK& zDh2Iqh)@!_h1mi?pL`t&O+a>T79OT#qJpVyp+e}9L6t}+7IotF?K7|><4aA~3KN&^ zg#Yz>c=#v{32}E2aQ+Y$PM?iKVd?DK0F~&*+n8gp)s>t)?J|884qXhvjp!IehF`|P zZL6iVGxg!-;f-R&S{MU}baae;%?^c|F)hA64t}|P2}G|*r!^Y8LvA4adZf}%MATip z{QRR|Khcv|j?i<0owX@O&E5q6s6;%?e1XL15UiUyme{2Z3@2?x=2!W{G=3DOV%Zpy zG-?^aZqia5@xPAy$!U0)bRSm(FJS$mnV7li5_)T>_tSTRrqA$PJ`N)^^Js6_(t0D7o|+(-IBjLN5Ba4^*;X{w4!1~$q0d=j0lsGiV>C(h=xM~xvo_#D=xrpXJVH`J94`Bu#>#m!u*gTg z-d|O#ZV6`l_Ydj*Tm z!elI*H5*HJo<;b*6lA?Fz}we3h`n|Zvq}GrAc;D&;#=B%;_gPIonI!Ik4#;#V8>ZR z#3Ud-J{F;VXRv1FYD7Gf2;IXa(rFqw!_3Ma(>zWfGVuwXWu)VF$SKSiWyO@Tuvr_5 zDj5ZMX!Dv7)#;wFp(`?I9b1o6h<^AC8PA^J_LXy(?=}inRyNQgvD4s0I%0aAHMn_xV!D<1Nx5Xs?{1KsPCkOx)N@&aM{G;h`eQ(mj*g`|JUFqCu@OV3 znb_-n1F0F=NKcK2=blxtHr6L`#2B1Nkkr)bLC{o=Gi%(y)R=Xdj}w>gAw4q-X^FRS zc=KYI8QQ_t-jw+0L5g&Q%GWnB&6?(5W^kRe8J=OcaX;|^Ze9zt@V|lN=efuue=F-rA};J%1~Yv^d zXT9SdXeds{YBvi;yqd6VAHrfEA@jv^#9sHua(7oUKk;DUz7IK71FSwH@0O*9W9E#x z*mTSvx09aY)!TQ-%O!J$*8z+sesZL~15Vw2C-sMYK1ZEz3u7xgxUWBs8wrn*k?|DK zLB}xJ!Gf`W+x20nmI+^naDHBHGXpb?t7(AGDHsohu{N$NX}niW+>rs{Q8M0k5; z6Ra&Q!Q<;oPc$Rfp``~M154P9TLAy0cS@^|8cUvF=i;d_Gc|*zHlGnC|02PII?o9D zX7(7hB~osp8}dLRbT*X0Yx@FNknhsal6;q%juDLPCgDI>3StkqlO%XBA|0W#3bzig zf|YsSb&sSq<;+H5-r*R0EK0^~OTFJD(vbl{UfO8t8D;%3sfJPJ)2h2!2!|e~n!g2-`JJt`F373}Ik38XjkE;>^xDWWrVu=?KEcB6x3}2Xh&l z8>y`iT@yQuUUCY_xpA0g%2yKUDBX31xI`v#TPrKZ&Y9Dsq`%KIvVz0Z6^MT)2_R|R zmf*b$VQON^oPtO{OKn?C+YoxDn5dLoPsjTpc47YRDGo!20p9);eI(9?q|k&Z#!#yq{9;^GJc10$$2r-w=W znx>v9Ol&9O$o0%Y7km~A+VDK;Jf@6xmi#>}$pa92&6>KVFt#S+c2^MI*RuHI0Yy5} zw(YgJvTp%NmbGCy<0$f_B22GH$8w@p&(B}Xsiv>{9Uqd!(ipl+uAt~uG?q?uCjMU1 zhm5Z`uz zCnHEKz&CZog8gCmT5=at`NJ>LNmh^k&DgV=kqf+&idn`!JMF~x9$q#EhKAHO&`;o8f-2V{b#3Y=d*k@O2678BOvnd0O(5Q7lu7(|Uo zytolAwl?6=>*|R`^vp;+J{>2bUZ7R!VW|+6xiMHg*;V4xdg3=SCX9*QYzoOhhTlSR z?SmKTsGofIFa)#QT;%PMv>(sd4r{Nzm-Gd(+uX2yaJIIBj?A3TGq%OJl^2j*^a~;M`MqVIK6BVOnQBg4&$?oY$jlL*mERbJq%;go&$??!29dlU~OSZ#;%^c{To`r zeloF(oXVcBl_EsNt5~d_KA!PmJvL4AE4p=xhEJrUs}wJjy#**$dp=A)1{Stl1bp6OuX0^Yn z1N4S$tNrODgB@SP5fA*!_-!H`Jv}A&?lsU;AR|*=6GyE0fj%m8V&LWJ125vR`;~6Q z-KZ_i$Ah~!5fl)Bi(bC)y%dJ1*c7}fs%A-;6cE=T`HC+t21es!oy<3>dR-OW&D@>U zSDa7qr$caecL?t8?tvhIAi>=&xCeK45AGHSuEW6K?(Xh7_|A8K7rSTwgsqFYm@}vQ z%)HZG)z$TRir)7gg+mP!;p(75mFHHG2n^8%QC z#A20OllW=@WwWvuu1Kwr64d3pg}>PNT?wkai)#cO->a0UE0pj3rgjELrjo!VyVLH)Zp*M;as;MEC zM8K54-JmHvI#ANHf6CkQ_G1mD;^f7JH5Z%mVTDC@BPRn)o?>B%)z6z8<610G&A61n49gkkS<~Ppxj<`yEMK36QoWiH`Q(iJP z%?X||{qr`&VX184O0RkSuJ$mT0yMMLiXAPaJCYA?M)##|DHd<~4fBLk2n#|@B9ElD z1U*t*NE$CdbHe#zc=w2hveQeRm*FEkJcep*{n$(6_M1x&5Z}oqzM}I_YgW*B#l~5<`+#ZbX!|T+jX0)V@OZJ0A2LPk zaZP{P6;v)3(S2$&09pe@bb4ok?Zhm)+fuJ^WHwiqKqv)8e?J*ZychQbUs054_-CWb zECsfxTmZ70A|2&%L>Kc za7NQGq+C(7)l(pK->mU{uoU6xG1#f^6ltcXeo-Cs$05a3)n&>_ALpGwbY9at>?GMB zRTF6AP0H!s0+Jl`vp8UoX&=_hd80>5ANIVxMwhkH_>APEbsaUW`Va<=Lm0gDdNGtW zTQg0W0*Wui@mX_Rb*IqNomASWq}DB_QBl*7X;p}&4}PZHZb?kKhjK$&MS_2c8O?XRg7l#^9K3MOW(j%!=)5!Q0#NXA z$oWv>s;$r`U^usizpVFnWt6&G=2d70*H!PqexFt>R|U>={b*Sm z2TD^#79OwGuLrFei+>}d%=BQ^738FZVxudsUbppOlAi$4VXbX9rxN~%2H+A#LwY<;S(r;PpGiJ}^ne>NzkAqn9icNbD0p5>FBfoPhSjXH_O^m@Vyax|+K75@%vUlj#NkBCnj{Je4ZT$kwihPaVB zKSGK!E9EgxCmjNVMSdE;8b7R8UP%7sHRq#ZA~vwBjLpHkv3jBiVblpEH&Af_%scJw zmXqYZ{qBip;@R+qbAS!;8s*o~ZS+OqlOMa;518 zJ8782HS(F86GAqNkU4ny<(l;Ld+?-tL+kogcQUS|PyF5D2j5#I=TEOXqVw(>c`#U*>p=nXaz0lvJOUjS~ zj=3_qwSifWAA2W2uv*GD6ZQaM@D%=Il#uR~uqX+>!o*v7t4b%PJMXrXgP=q5!{g<@ z52rP+gutE4aYh@gsOfny?qo6z@3#Otc5Wk*V3PzxNk)*GJnz9ReE%)30jJ<+!!Enj@?2A9LQ z4|0;@n|z?^O<#=PQZe~C`J3G=ottrBM5=in$}LGErp`DL{$2l7G%+qXc(;Yxp-!SV z?wC05_B{2>3`mI!_m5b*NHP&d%!xiyJH9VvR?R+Sb@mk z!(o(*celXgVc5s~|Jl2SjcBP+K{Avq(Zt}o;~EO6udna=uz-H%O`zL=*-q{q7v0M0 z$fPNpjWJ7SvEJEGQ$6*`rsgr12BA_RX~>hVP3{>M^3#TC1} z@ZVT{*Jan1k*>MpZs6|^xaKAK5IlY8FfZ*CHz9m}xF0;cvl9vo-Hfx-D|-Kb14AqT z1%S@a?uBD_mTP2TgT#aC7o+0>mGAF__1}!3{NemBGm&apcwr2?dPeq#pP>DqUrUT* z%r}n9O_>BI`U*b(&v%qvQwz^NX%|NRmdK=9xE|F40=xBvhDa62W%MWx(EGE7J)Oy4LZe9$VouHIs5 zmyXKc`#(Pjcoq|RMfK*HfvV0REWS?cRT6;t$d7)XYcdf3%jvZtB2*vzd;#ka8V&$~ z(Ea-so#>uWiQ0^N`#7xLPpNXt1>slmCi}q4Rha3U<=}@8A0SH!!b$_e0d48j)Xp}W z8)^Bqef_c+EK833<0V8P+;nTZE$Dz3Cu8=D-A;C6$lX>{qkoF>I!Y;J_P9JT7K;1<5qt0{t79&YeFGM>rieEL><)c^dEKFBvY z>r4iA{vr52|G2Ixpth2E{%7j+?_W?e8acJRKf9zN`NS>jj!W52_(xa7D$;hBGomIr zs+_rE%g!o^l*CjFQ1OMscum?4VVrLINGy_o{4&1}#>bWu&D94KxGfwo_bTDs&{~AX zH)%h()R64VUw{O?kyVcEbsd-?W=Zp!rxBQp!Vk9tp&%fRlAAUc!E%qUJF#>)7!{Fy zYN#jvhiiCM^WMV_9o2Cy_{=?a?>W_+@w=b$dT7iytW{{*^L_iJ$F?`Ixt!@EXeLVb z?nEiz(>Je{Z4+x4`%%Q!%*ASOGv^K-@FM5;hgF7gmj~8QIBqM-U#w#^$!U}aH(M4b;a5 z<*nQMKZ9IBiR0R1+6~b7{p369--xT&E*+}+!>hwv>jSW9W1YjgAp$l?F2_m)u5s~9 zM&1{1#5SAK=*8Tb39GY3JiV!`J&|i-^CKj08z8$MUPVw-1HC@a*Bkl<*?K-UFuIR)`~T8cy$Imly2+0f#D0_$n|k=(|K_M}ZA8;TXAVje zd?OW6dU;{A5enNid0$1qYyrVi%j=mpKV8%h@SqyznKTrMzRxrOW{NQ7M~9?;>ESpO zD2Fc|q!6|riIQEw_ZHwV^s**)!jZ#)-zZEiRH za@{s@>aYdMpCcgY3H|U&tnE6Wc2{Z{c-``dazqtM`eaVY9f|EKc7reu9XljO;H(bi zuHycCw$irzDR`xz;|Pj&B};PALGS_}m8zPEH8CzEl7=BryXBV%BzGC8nOT0Fk9Yh& z*G=XQT&MRYiXE?7+%cNMo_kAHOZq&%ud~EGk4BV+jH$m=dFBDBGNZyNg)=FQ#+9wS zH8$cSQ@3F0trl?M@ISL6I7$o&`C?lIK*Gf$fOskX1Cn< ztVAS+7UsR@h6>F;z8qI>DRZP@$3~cVA@&8~Z(bL9UF9l{=p6iiPQ~fu3E&O|CW8^0EEyH{v3K^UsK-9Z=d`zoPuq_!8y}W;3eahc2^1oj0CL!wG8*Xzt-l=RxSU-aYWk5LUGbnQ<=CFP9B`eH&loOhUDq7? zRi3cm%xo?!6m@r_&HvaR^8WaATIeUgqDSB}0nD{4!F9`&2ZcgS;!vZ=lRXHlGxfgF z%)CL1+iAXcbSyR5p#h{Mw@=Aq{!C+IH2y*t)%xIy4GeIY3+N72NTj^FK~3=e+;Ha` zXEF29gX?{LyTK8IhV(3B{xN_2h^{sYKjy*}{O!&HX92y>na1?|EwrYdd#BI*mCVdG zu4Y;cQcHoXs3OLnnHg+K9yFT&f{*UvP#f4r+0ef$`R6wOXEFBVd<{#@5gv-+dk<+o zoc>Nd{JGytKvTffx@yclwtm5BL)?$zB1vT}ALO4x?H}>AWyS+yvGR1lvS94Uc+;RE zH%qlL3Gl~cj6}363?L8kIRCxl_P?{CAoM5|W8b;{iQ_kceR>)iAC)s!fF=6bFUf^bd1tai7x6R`9ROuYvqED((?y zibU1l4+n4>DXA3sdvtVs=ue?E<<<6*i{i9aiJwSWV(DL=(5i|=*d;*-F6#d2U3E>} z>etSc&U3^zYF|eZvK2>FBa&`xnq2S7Q_>~p=A#OOd~s41xF$-j&81R>7ynqC`9?0O=*JU=OFJwuR1Tuld;XmyFsOHGdSmqOmnsLfRW&m)7m zblk-5@4rt<(R7FkOXD*R&k+AcGKJ#_)^0I{DJ~~(XN72wW;~Am62aKBWDsEBb7GO zE6Y_kLV{$cRZObd6#i*(C0T>KNqnhv(;M2~o-J9CAaeX%bHIb(UgGlzORc02+I0rAoV^R^_t0+~Ksz0Q<3woQEs8LMgU0($6do%5#Z&#Q zW1=A9dKg2-)jj_ybeMBbS2I{80TL$?K;liMhKTVQs|)~z?FiGvTHu`D1}96Caps!C z+V-=o&aA0)H~(F29#2<~bh{Wijia(rF)TUUfwI#z(r-s`1lc$wi6bDarvYH$Yd|3C z_%a(tiwUC1!C3zihS<^REr#3C=%2x-0ROw4-IDVdgnQeOccvNqg7h&O9;|NKeVXxK zw-LXuI&L~QoxSG?tWJsg_$=r)e8-JFl@)#z?WhHeoDw}Bs0y-83pBM!*-d*haj~z1 z$l4F`_+QJx@u?DjlP5TBFx^#1BQe8xwO#iRSLSIL%Vl2qnT~|MNsQDoyhXdFaA8mA zvu38dM4+o`eD(VzL&g?dkDV$5JH%A|iK{ExdrCu{GB2M2q={fDb@2LNq48amP3!R6 z`P<~v$dMgAc3BN@GqdL~^-o216XiB{&98F_FGDXkXhB^55(E*_8YS}9E(VenLA{t2 zC*1?`JuP0Q1H~`WmbOJCmY6ScZe|9SMU{XJ2MxUB!x5s42fT;x%Bq7YBH>^`x1{dr z*@fSqZ(0hocq1~N>*O+l@r6&d<<==rLU%8`G@`m{t0-=^(a;YC^6fRK^PcHx2x5w8 zO}ThG#%Vw{>U=6|ZIrOxdr&mcUhfC1gK4H1V5H;EOaQ%Qb+R*W!$<$bGI#K^%lOyM z=GG>x`|*+Yl!U!DSe_6qJcU=zFleoX!q|P!j>1j3GkHsGy!Rn)qSy(Vtp0w@kNI#EaAZ)0^?n;`YWY*W0xNi3sYv6cEgz4ZW;B0XJ(MDBP9}f{V>Dq4AH0I2{)UH zYbP&L-t-Igr!ct2$6odZ=}3Ft_8P>RT98%0uLf6-IJ|zz4-(w@aF5hJFNwIDua0uq zOR!uDmBzF=^}Y%gR#%{n?&g**uerC5pWt zwNr%pHue+ogMokO0qa5@eQ^DS&_i`hU|A6=CGE)NE+aK>E+bRfMQNVjX=pmDO z;is9QjM&bW>X@vgYa!OVa{bF2cwu3(N(m~ax@^*8dcn7j&oS|Tz@io~UcIV@ZDc4M z03fc;+8r`$lYm9ND=H8uqnr55Xc(&f(76mv=KNZ7T*StPTaFnbvA zJf4tUqt{x3bC6BjvjpV1`?d!VA(zDjcHwrh0}g!-$N(!$-k5gkL&H8*^?nxH5;!pY@JQdUMWl0vJtkW8WnJn2$J5#b$`WYHdv( zif?)elHvc|4}Y!R#w}e+61B$+yfv&c54fy+4^!_j#T1BIQOi={q&@G%5H;md76Hel zLH)2|^UukF7THmSWflmkbmy5ZCQ7V*JJuvf=(fZo4DW0YBvm{vA0Mpbh#SLPy!J6D z%rvhaEn;OtRhytSTk9~3i0N<~DXh3BTi_Mh@wJUHEu z2FLQb08JI=qTo+K;);_f*e%6a;TPyAoRRXDSPgLGm*;`Ol<0Bl%|)vIp}KIK`0xHW ztR$u}Ka)bz!z~kUAPSfiHa{<4%qk0&ppIk_6#4d*5Ky*Itn}S$Z0i-33XO7R`4bl& zn4go5AP;Ar`gX-w3H3HO5|f-d0$O<2wV=Ao#$UAWV%}xz@BpYcK~Z}vuB6+5AK#tU z2;wvEyHrU2^KB+h30&Q)bG+%s|1STW(Z6SUQ9NBNQvFBKGbk`pWG$%7Na}_(Mc&~J z%Z+dMJ@Oj@C5cQv;yA^}cJHsg9|DI>@LqM_8yxz$J=^%PYQLQh!jz(Q6JR&uS(5py zEr)Izqy~ouT1*l9A^amn^>nr1EBq|nN7F;vhbVrNjx_ba;Oz+$XCds#qC8N>*McGB zL#1~XL2fSpR4F1%ZDC?)1NXCibqY};l|j)U7RtBXMJPW zu1Y_XM8!Q>t`DpAUOBu5jLj7AZ$j6H{9K5$)5c{Ydf3Ls#|Y4>EzMxzd+3?h+~LHC zaoW=7E*nudLbNtFDMTGGKSz>s>|AGx8Xw3HjsooEUup@cwORJ0+auOhoG$Miln|~> z`D@W?#0K-D2inTSrusPLy>&;NKgL9r;*rAswY6p7XA1!MYn zf#uP|s+i)T>)PO7Oze?`67!w0iSZ{x*pAgnIOF~u4`4lrMr1S~$vO5|N`DER(7|X|krPH-7y*wpc?d5Tz z(j;#W#e$V&d-LMrtR(?B?n)WUyxQx}Jhr9PBIGoofEM4d&vICmpQZ)-CaJT zE#<1kTBf6688XIzAMvJ2-f6E@g!?UxUY6;~FG)Pn@$Qilt_v`^uoaCvImthG>F_@U z`S7V1a43(YQ75qIw3a%t|)e3M2HnCzE8R$v4V^;b1CzGbFXG$y#qIl)m-Wg%PY7@4)7GbBp_!O5f z7~Jo}OEEKwwqQWLZDlgTID=RpC4zl@x-!5` z3pmub_HfF~#IP)+te;KTw)Rg^uIYBU7=?-}+;>K(Je+F$*xjDoNrdvFM$;#ZW;+oG zPB0Ib8Bsu@nj`x`u(_z<=W9{};m%;#M29iucI#`EFPA>SjwLe+rSNreV}Ui198LUa z1dUe6Xrv;M#lb=xB4eb_W`Eh8b0ea4qMTEU2UliHd7IC*(U?TZE%Hn`ERSuNsbrsJ zplfI%Bcz5hEyd~WYV0_EJrkWN3I-NKud*+U68!}76MK#I$FXo3S#QTgEIUW2C`){< zc)`GDGU@cJCvAaRcKbVGEWZ29g4!4!`9i?Fp7fh5y>5GE*=#KO<~OTC@~I&HWQMS6 zoOamQ;-r)3Im*@07hB>6L=S_=)3^Cm^s$pqLf%)mK)a&^Vb2MH-+%FbH2m3CXY@TT zLFI+we^H*{gCX_w^CwlGn}ovrt7s=WhpAlOZvvfe7nnHV5p@bn2!Lg1 za~I^(XYEV%m1eN&XqgiHGduB_CukLnyA*K8txTran`CWV-}E4vz;>P=@K>JBM(&`& zyts~_PuK~H%zTkS3FBVik$oP7w&G2DmD!9JuA<+YiH(?neXQ_YfXEE3McLT!F^1Mu z6RIEE+U`TftYB~lR?et5W5SBh2WkZb5-cTj{$2OD}^kVDQKOFI~#WrT3xYX zylCsm1;~&!tNSY5bgxUL$Ee^9Wig4m%S=b;@sCcN`#eyj$0}Kt8f@{?#&hAci0P_* zraWSD1;0fy74}k_Wn2p4Im0XOyoNR|b^oHZi;ex`coEsEE8~aF5-l%>=rF=87c3s4%Oj|&CU4rDxa9rfG;}X9>LINrE5?G~gq2JQ!eT|_yUo3DA9$oJR zgQPpZNGIlmWdLd>-X{d-VlK0c-qfbtRr zH2-xxL~=0y^ffh@Ot_@4H&{_O7ZZEDTXj=rfWf`vYgCBHpxEDsQ%ewkBdxTY(m+8=$=}E3o^^B=fIiD$t=#)3d}VD)%;OkIE$XtuPeRlYrVe;vT$|5$ zQDzuCyLj98f@c1kzWdnSB)J8$#n_Y|+oIr=gr+;CZ7y@%hc9`()5V_5Swu*pnTi4n z{!2NUg%oM>GI)8xVvwFcu;@(cPWI63_I+QO^*doKMI-Ue+oNaLhx_ro!|;?=5X)G1 zxHE4LlLhjTQ@h+*W_UJ9+}Kmn;onz zDgvYD$6ArCwv?1tmOx%1U$)zTr!`p@5?n#3BvKR+P!AVvicZm(goNium7DlR0Dfuf z<*?&zCKC?Z143uuAIppY0|VXoo9zr?EESnrWiSGEr<`xzp~3cJ@_Slm0+1VyX)c$M~3?L!DM^iF&I8lVBt4;X}r>m7fm`?6ez6Okef41a4s%OQeIHdiX4P0e~eWYC1OR z<}H%%b%s(i6Z2Ppd1ziIQ81mY6UvId_r#lC#H#iAv;Rc~tlhsPxSoC4@u-$afPNyq z7Epp-*k;Q1EJhJXoS>!n+{}AH&|!p)r9QC>PK3@7%8J3fA)PcvQKokmQ1ifcAkhXo zc}gjYF)t-WG~%;S_>caYG5l6wY8GzolV^>ib8;h==zN6)N{^xP;*;$?vzHHGb$C$` zFvoDDKa<%=#(`=3napAPIbMFJapu~M8jfDD*(fz_R!$n|@PWnEvy|gU? zW&vQ!YC1T%xzPCEut&OnA+1B?+P2z{_+7z7@RE=tZ+9piaL^Q)SApIv!kVU{CK4hk z?4t&~Tha!uyS>@UQ4Os@Cu$JuLfd`B+y2N)4i1QC>DypiNAi7&k8=HJZ~cT^x6tQj zPxfwa4CR~5i0M*^WV_)y%%b@k8fcOMQM1^PJBC)|QQp~6Ii1KY%UYL*zUHeJe!=~yb1PQ3=AN?@#i#)REPzH{S z;7D~^8}G>B1!1ez7|{8d-~d}Fsb@|n7AGv`{PKzGhyI=olA`U(LHFE^iLX`<_e3cJU1I8bRBIp zwvE>BtmPJ7R_-q#o-GLL|lask&xSr*$k{;M~v$S!L>?C@Uu@V`p(fGBd!q4sDJ>B z2d(9ANBo_2NiM*U8c#1HS}9=8p0tP?Mv6#3KOgat&hrjYL9RDK z%HtW<^fnY9*ibFy6aVZ!*3p4yVTLw!y-ADR{sPM|n+uJcflPJQ?@n!Kvjhp7IbzgV zB#6T+FS!vR=z>~y^mKVGu$h=J3-dO)hdP6W8TzkFI>0i3#9;P;T6Y#t0ylx9 z+fleFR=?p?Q&TLYgj9|;6XM4REVV-40f*_B@Ct7-Bqs?0d}zF~wCr0%TrN)tGos#l z&mZ0KFH6DnDde2`EJI$YUvsAA`IlAzLvwJ=WChx-x6IvXybR+#pI=+s@-YE)q;G-E zgH&q~hOp#vwnrz^q(pc8lkf^p?ar=$VL#)Y_0{b0doJtr+3eY^O;k!R1WU&}xWX)s z;8!X4K##Uh{hl(&4VLLUm_l5yZaO9DPGeZ0TSpaU>%~b!uKm+~C4yF+g{UP&>=a|aCE_no+sULE zsm^fbAhrF{5s}RWHSxudb@Ou?t3*a44QzgtMtnAUt)I-14Xc#*jWo%dHf_BrT!7m10DKqXX?Fj#r%6-#WP2glyg)(60frrd}Z*nlkyrAT=YszKn{ z3EFFGC(PipFN{M#?ali|QdtD;ZA2tULzv+huvlCXQdJ)18uW}Z=1M^le;6fWDMnvR zePTV(Pt7#ksc@k2cZuN>aP`tP*V7&z`k~P_t?y9*NBK~)|C&iS3b@NxqA7xjIzaQ_cI)+Di>&@xptlOe+q{t;-?x|i;K2ki;_L~Q>ppA zb?JIS_!S$InF`!vZ^rt79MtVqJOq7}1H&ij&~>o;A;lVVjIj~v@8mir^UIV6@^;hF zUQr}VQTiy=+E#jsE2)1v>1qwjj3_(kyGn6OAq>} zsY5jmKfuJ&>tJ7Vy6Pw43~wcnOB??p#HCKaTNL5#m`4`2u4#ZtW;&?_z z<<4fLri(_)EW8(Nq!%1jPD-swd(c{}Tg?pqxS7B|R@c$u>kU&}nYM=`8_o@T>Zc+- ze}U5^=r}D1+N3_QmwE0RV5eQ12KYd6p1v^F@SL(r<}wdw{Ob?uN1rb2h1YeWcHq7i z8!)x;lotFhng^XmM&lp-T8phFbYU0s8|o={_-%MHM!Ub^HA`rCU9CLsoA^FqeZqaMkcJG6)*OVQD%T zewFG^on>4@_$EEZTV5%aUG{}kjwmv7C)IlH7eKnpZ694+?EYR+`FB<rJG7a?Imr3d!Bsb9PGEj6*(hLi0+dCQ_Mrj*a;P6`W)5gm1Lts`yLtv#Wd+Ix?wT7n4PU zS;A{~bP=ePL*wkZ9ro!X8<`WZT6>baG1_iLmo7#Kz7i5g(KPy)ycYk-r93gT$o#~AMB6A0kux-A{1BU%Q?GuLfK}=m zNc5)OU9%Jr{fZd8Hh@{QD;9mKy%eIWTWBnuqfmB?IF#u)A4*4-;e!0gf#Xb-dzX4A53D2>3|X45 zYWQ#nGcfXZeIXm-kOAaS7`2XzDe2reCGqoOjXGcDxZQo|D^OjBI$i6mf!?Z5-Q5lgr znB8CJ_chnmN5^cxAsYd>K;;6fPCI}fdfiS$#OWVlSD(b3P;f^@mu}E_-z0+hsRbU< z`j^KDG%2gDnWSJ4C%Y9Ws{KNBY6qt{ zuLll<#CQ%B=OX+P2Z7sngWY4C`;PEvu!*&#-v-z?2G&so<8BiQFygfUtFbW{IN4#c z1sI0OrI1V8t}$!G_9~#)i2Gh6N%nco2PsofF5$+%XN|C!j=M-^StshA@$V55AmPBQ zvFI^aP2Y6H*6gZwmzJAbofvLN`AZeaWcB5xah_#L;d95bzxTou(xpbZYW)6{?@ZuT&D)_; zJj-(nEPxMXl=SW^8n36-bBAGrIYds`Wb2(YZIs`L#b|xyzn0J#9fxxhor4gQF@J6j z^vmXtZFk>ZwQHn0Mz=F2at7dBH;%D8+ip5L>U>ZNyNi~c&i#l_ zal_7#*-u!{%!)rAK@`!1V!A3Y;FQj)XHS`DKQs_s8KckDnx7S85soWwEJ3BzFKUv2eQD43s1R7%; zds0V=sn&ie#^V0)#}#$A*jj<#T|rmq!Z65k_J(d8omdO^8{@?h9e}}4HpV?>_Qj`? zQZ7y}dWNX5b=WA_!L=N{wfZ;Uih1pKd0I%QLz^k2-)lUnL-D1o$ zZ)w3FOB#VM0s17o%mkW=%5z}MTR&0`zz`-eo1cYS%Fkh=E81XV2rKN-=Y?j>uCdUM>h{1nLqO? zE-lZ3VA61Em#rcyDmpbjO+nJ~FKT!-CWrIvNf72hm1u-a_`-;7uE7*_JJ5x; z_)KS)VNU60Sec*s4EWv!}jli^7Ce!;^j2xKOb%r8Msio_{A3E2=ojX=mc>!|Vai;yCAwXti9j{)vHd_{?4XUUb)9=+mSfX{}6S&v{S z1u>`p8I|*^dG`n!!cTHTG!+YXvACQL;!FQMYPKHni{$utKJ>of8EIikzs0Fro`3%+ z@R`sd5UO@~aHSE*hrH(B+@uYYU8oQnHT5_uydT|98I7HG#^Fo~mJcwnMGZ zZKsZpQdh=CE0CAuKSd^|9El6ZEKN>$)^D@hOMb*X4Sp(~Y%|)$ev&~pk4R9-QSjkn zW$wh^CMBj!Dui^8DxIBO4w@maOQ?51)2lw>Kq2Elh`Y*eMY0yd4PA5ul}gBkN15Yu zV=E%<^iCB~rJ!&;rLg8VLePiy`}qxD)}Veq<7Fh?VScX^(4!({an$Npv5S6yne;+o z?Wzk@or_T%h3@kp33s_1om;XR$H3A59Ue$XYu82<_ePELr=UHi$rkg3hODZ7hw&BV z8;5&8?A+e_%3=7g;bN2^PCF87Ah2m9b#Iime62FZuU z#95liUn;EUVl+H`R++kbZ1jb2$w>gb9HRBPB~1AM1WgurXh@hP)81TpsZC;n{0?bx zZgZ3{@Q(f6`TSK=n>d@o?iO3eFX~q1tge6uFXuffjrF=C6v)xqY|HBT>^mkrOGniF z!;fZiRdUgu!9c5dB*+ihXZ7l24i3_}1w7I7B^w!TwfpXgK+NUnsRr~|Dw^0+B#2C0 z_RQ|9+K5A}S0E%akvdb`c!9DiQHR0V zQ2N|6p%rBi&TdU9;!NFzKyJMIs%~9NAY!Wr)C}^F~)91Q~8^6Abs=n`iwRxczfj$EUrh;i|61Llm)&d zSz_xN?)Kb#LfIWAsoNF6&mX}J{o-c*o$hr9?>eaEFe&>C1Sj*WOo0mhk36T>h_s+S za<$(d2Rik9d5{NTxKC*1Tlwp%^Wf(6^>PeVYBOd5hdrt*;^du9g7_=)LkqoBE;+sPM&K z_xSEE;rHTF(A{}5cSgykQTBrC8{Y`Ue*OHK=NW?>T|WVem|K@W>rSuWti8Djpfc|! zr~VyB-5qH$@!!^d{&+oPHsi0?I#^n@Z~gSh*ZXx6BWyR>+;ksOF)0jY<*z;wnx%`% zH{!OZ&ivd}x4gl-gNEP=u-iR0@P7ZR(8d2or!h0S$D7&SY$aL5Ugx*z;-PE^)@Mo~-pODAfAWIIEp@-$wQ>owm1j!^v$mVmIUx-Yy(=57o_GrCJ8hO(_bG=}frU^mF zkWvl`G|`$rs8O_^rV@0)F8W@38JJtAXl6}M{kXzu(UN<-Hp;1Hq%AF|mJL48TrqH+ zj~hQq@b2m6-co@$*DDesRMR!mjPvUW^=7K7v;i^h8-2dbpLx5_fz+Qyiq#lNViqDWu{$4kPJhigQK#Ijw`Q$sNC_j7h zDcuPLI15-U$W&H#GffwFxh-b`uNEsWx5`WRhCu>}-tP2+Jd$vgC2V0ICP-oJXL|4q zSW=f`qR7j(#*MwwZ{#NNMjb|1rOXko-kuG1fJx5moM@XbyIBy3chr#$^FUXMeZ-YJ z6et1N{uZIw*Cru$^(-2>kH%e|jS;cZcAJ@EVmNv42Ro&`!+8(dW-+ZQWO|vdO@%g+ zZ!x_t5x~^VQ$nHdPq!$(VKT+oMP+wnv~Ak`mJ9C1bZmhPo0y|<)20X`DqktE5(6s9 zVekA0?QFY&m{^64y!cgxIhn1ov&wtyZ-38^HNV#-tviYIfW4m18us*QK5hv9$ZOs3 zk|~>sBCg&43JLB`t}|2r?Z#je#z4YsM}d2MKwv z0M~jerXk=ge%N-4i-;b>a(=ePl+>C|Da>QIya016yyFp3n*LnV7!IHOKTSewSjK27ayOGd7HTf8Qt}<0`5Sq_GS0_eNwyUU>TnuczAHrrZ&HtGsc+O^;|NR zy{KH@p?p(rRm;mgT=xkq!k3Fpb)qhDH7K0_@^QtFDSFbQdHlhY6kd@w+#2|hx;m&)l3EiIV<~KfkQvvG}ac$rB> z>EtJ!RhNn1GJpp2@@1FtT$LwTFpwLU zwng7gHT@F0&YdsR<;QUfMwo~)R$mQ45w07z!)ugsX5l+hs0zi@w{JDLCfy>|r$5}g zQAft(db2Jzkdq#C2i_amzHyZ16LdimG4Ks;B>W+lQc#L{d(#@vXzF?uTf}j;jKx~G zcMp^8NsjU&f1zg!C5<3$Z~V~61!n}QeC3%T+*?H?|4n?n9+io*Tm45X42WO~N*Cvl znn60(jh{>?4pRWSgtsSq35xn5S`^1VhXY`eYO4BIqKi$cr=w*e{u*}f=?n)~3=$nF zJ;%TNWS`~l>y;MtuBs+Vi~~=Y?o3oXPuw9^>bI`+{(eR#2flyivdGNV9nKKBigX)` zTCsKVvgV0udmoKf_D(k0nFF$55;t6=I~yMD<>4!H3r!)4);k! zwzYm3wI8aMQ7>G+1ivLL@sFj?VMD}~LqZb~nSDW~eOdNPr1|uV2^Ws6+boqA<7MIoGRwIsmjEb&z;lEdzy0TP^fV=a2zW8sFEZ-!fD=6eQ)Xr>}) zF{Z2|D6tlawJ01Ld9j`9$P<;%G_Kfb zucL3$8I5S1ZU{^W8`;#a<$YI)bk#?S9$kW#c$eU*-A4^lUCr~MW91!K-4Qa&OWpU92z47u-`vp5h@mj0d01J)Cj zW#y04)1(%sl%8&MS^DQg?mpu8koQ&UTUE{*CeQX1zl*kXWN^3EyFZykH>5mq2c`@W zUTu!17oU+av(@-AR}UfJ{x*2LM;MNN#@vu%4;^h3%^8{IEG zKrQuI2|mS8K9I(PmxXJPx5Q~j65t_qwIVtxV({p|;`g&bw{)D~~TM4x%_HsjYb!S%( zw&;HCx8YcNC;?qm-;HCsaL64WJDBCI0)foQY%fNmLQ8k>(JQa6A-dB}&_(Q)`#*t$ z_Ja!|;ZCow-#TBvN4Os^NFlbLAT?N2Q)Abff6&QLGuu1^b3~^(od+)bcjZTirs~yL5xaY_|H@vjsG$j z?|yfb^R2&7!Wd^1Nc^3BfAAt)oj;Q`$R9T1kblwjc$RLb&|}nEl!EjFMZO&HoctU6 zm2u`c_FK@&3|)sjp9Z-N1p8HGr{;UpYldn8cT{ z2k75KrFL#K;NdY(>FEh!fMT86U!rmv!2;T^#)GAuNog*$5#f>5|54@s>`^IFDT5Ix zu$<#*C^g#Y<^kc7Mk}mC&kJL)=ve2xp6?fV zaEtrXIZ182fvBb5pYB*>u=NwvL=zm?yvcN`UN&}_)#GhnU8^MX&L@wT=ChD7zqo9Z`uI51%QSL!i}q8wtsI$?TR)eeP>a$;Qo?(4T~FK9qh zJHA3|PmzT^53auyHX_urSGpdJyV=#G}t z{Exk(nx4w z>-;-h&_=W}TRChq1yV1f3(^l5Vst8&YVy^2{rzWT7ds z%aYm!ZSU`7Aj_#(NeQ<|{uMki>r|dX1qh**$3~~?v_(rWoK9V5AbaK#vT3O?Ax0^) zK*$|5)Pjzzg>I;}tGcJb@(GLt=-r)s%3yc^W5;AVJ>K{-tjtSEM1%GH$@_S&mVvqo ziJ@~w1gYe8Gje-3OB24L0Ki#miJl=?f2%Hv{k71=RQ?7%oKf{Amc;jA@Akc4XZkLv?(KB zQya5C=}n5jjPHGEc8X!nV@mG_v6k#2BQOmRt8y4Tq8DHNhL^~Hs(%6?LXLdaRHt=J z^BC(H^)*Tl69f1zTJF$>Ip<26V9>abt#yq)ONau-|2AIxBZe0@3@qD2M#lR)HNIDH zqkkxIKm8bgpPZ?sIHj1ZpEasFO;71S+Ivz}x95;QJS1x4?GzIe#kcl=PJh!Ubs=$q zXvnR~hhJOcM;Stwp* z5#Ax`BAcEfceOa~$u-drZiYV3ucyPVUN78yn2;!>r>q2XF6!Z5Ox1EQ_Xs_6_LNF= zjn4=NgHT|#T@mIIPO6+L;tdja($WQ=SrD4MaCr~mpt0S0tqaJzj z6>)_4%NEl~V588H*88?-OYXCR2->0KhI=ht#Ki{zVYN)rXguA8D|2-8uN#ghpD|`5 zrJ4uc*d*gC>aXxkpK;k*Srcp7`M$lhylz|P%6j|5LKEkeR3q{yMDA063Pwf+S-(<6 zIEP$l`lioe=SnOf)5Act2XH?PAy*@wyoNyC1wCJiINVWL+9sP>p5_DXUN?*loQI{YgGREXTj80Zyr>arRGO#mHqFn=h-Ir?R771qf&3v) zTw4nZ!iJIbj_Q1SWJKQ_cY3pUG6&~QHYj$`oB)|IBU#sMLtsTK^5IeyC)e&r|H9L! zoOSuo)CLaNSTWb=63C@AH{`7eRXi}hEnUfI5>{dp!dqoxI?9n99;(?hn#)WjTW%a5S3K278J#w@a#*ZmBa_U7r?Ru;_L0F_8@$rK?}x;j*jjVdtAo&ibe`#-a($8RX;dY zIryOXW?t9zhnNaqf~x@k2qbUM!@=0tMHF-SlK~(01^xkUUTo`Td%|Op)jm*+anH(|ED4Kxip@ zb}llfarf16<(4!b7zL`|#m}t>;Go+a<%tbMa>FTfe5ukR%mi&<1}625(jJ1|tjWfI@3?;mvKwlNg8AKe)D_BYS66 zAEWJMK=%h4Mx!zo?QWaSQIH1(firTx#%lpw+`sQls3Y`F$nSz7+ZDAGhW6aUTQ1G5 zxQ!&dIr-1h)p)aw2qsZ9?-DP6E8KnxuiC{|Rn)Ui6$MZx9~d~$D?W#14jCwB0xWB) z=~r!WjHbbA5zxj0^u2zO!ymZ{f_=J0NxB+*JRVj~yubO)aK+!o3B0_=Z@do1SiUG^ zRlxvgwYE9{=S<*o;+_j|J5OdmUY=}J75X38TT&e$#)$Ph+6+xDTmn`_>M&=*`fpWc z0HIIieqYj?aAB4&*ldusVbj1!oIkOdX{1<2|H4mzAybDoCh;xnb!v1<(v@vGj7>qB zI`488M^+%wXlItJYh1pkAnZ$M_F_j2896L?3O!PkYfaDQKSx+nwM zL14@SNq^S0CyT)(B!QNu8k$n75DJ0iPvDxf+PPR%o8BME;mDlY&v^$g@U55I6GBa? z`0veRguj`7#gfAd=Ze7G45hvf*_>E8DAJ{sLC~`VZil5k+vX?qYesl#Dyq!nc_`t2 z)xzfI{p6<1h`_&3%HOC#DvK8d@VQ4|75A{usKacgZEPqOCJaa%R2W&DQwWnyJSB z3>R32hlff5PDp}qeNDR0Tms!3x7pBOgtz~a<3oH!Q9Xpz=s=f3_N+-CebcW}FdCcX z`r`c#aepS*mrihr*z$|Sny$CM$s9-P!SMXl&b;zDVjP`MpO6{a;+2SHKB^{m_+s-} z|9KeEzA+aY?`Ptj4XNl7y5cn!dCwA2OuUyO2~A(6ukX-bm|da|yurM9)h}1HdbsGt z$c#FXDz4pLr#p@B!#-U0@y831&YAU4>O9L>%Z#O$A{7&&Ytc<@Hk3}QUff~;yv;W7 z_Ml78HZg_sa*;WtM#iW!$J6A%Mwr&@QuBhK3M?J(h_<&9C^H38*QcPUbZ+#P^k1Xm zzbjy^dRCokrF+5WV)3a@cI1#~!lrJEeP30h|2u*tJrJL|cWd%{|Cr@RJ&hw+)qKKl z>Y2_usJ5^+thR&Hr&!FyDAb;Tp5$hRICjHbrRgl&{}IatOW*4!@0+cDK)6;#j;>>I zC`RBJlHoSyn`q06JiP;D!}1pbY+q>`7ba;oJN?1Ja!E;_fI_)l;_9!_lF&k!purW? ziR}=5Cj5hh>RKiSHOBKJaPVm?1sGVq%CSO^5Q%lf90-#96I|JdjDl|i5u?LB(OH>X zA7C0E4Ai;CrR&koF)eeT(tgSR|-W9(G`Yr1qMuR^D8${o)j zrZf78hV}qyMW>G0V4S|Zj<{na2;Q#=yvJf3h8&4-VDKB+z7ZL)5;f8#dHDzuRFXsE z3)|%MJN1KpBJ_)Su{Mkd0u9->*Z`DgiyqglW5j<}{XSDo=QbiDqowo)iHyj-)ZY-- z*T6d%$2vFUhwfEwb5N$+GxyMAnE9${+n-vL_BG408HjREXVXj{qOUuW@Z+z+-7W;E zQKfW;j0OZOSb8TrrCt-Agutgk-((hTt*$6J7rPQWH_YZWizxRi{$-4ptvU=I&CSe@ zR}3!Gkj;~cp@Gq*>)BM-u*iVqT<%Li14T4~3TdDWzq-G_sh5ml)TzYBYfy>OI8A%mxwrcQZiOI)V;Ua=;96S5 zkxueNSC7pI0gGdAK9(ZzwK<_4gZ1FgL2y>&{h7{viG#{5TA?_icn3bRAeXl1S2|X` z)!y|*9{XyS-rq2O2Q^{NnA!5Z3#q-_9!~cKp#Jo4d~&VP$Vm|FeystDec(*JGB7bt zv^x3_HA%d&IWk2Uc0>Mn5fj*sD`66-82Tm#2wXQ^T@xVE_7NCc&97p-gd=MA{;`j2 zXK+7av7=KMq~ByX(@q9eEtOwwn$!Kqoc(~FjPOxQMU&tf-!cg6v^8iUy92aVD#6|2 z1KXyfv$?f}C?aAKFBh~t27gh;^$eZ`G!SDnIpZg^iQ+DC@=F{VtY~nH>bQ$+0oDJMS z2X2ueOfGp)#yQ|Ke(YM;jGEoW%^Tv0HVYw=YlVFc2N+0I6nWX}<4Z-8__oml&&EYC zxK>nod9(ycuvQP}%a~>FR2w*-O+P)wc~IgL+b;l=cd%yJ+LURQk_qcc8Qiop=?nWJ zpa@?rxJ!G+?-_W*yWn7aP6@88?Fq`9H0ClY3EgWb1U+9an&TYdcTbz~aS}=O*Dj>F zyPo55GG=^|=|C;!6(sPE8+61kr=3OPhC;}V>LD^NAP%f;W~poDgh_292;Z;Rmea&z z%;1YMVasRPUp#l5ICU!v$BOh!&O%jh*~Y0T671;>y|*W&04K!Diw-^Px|6TP1Hq8^ zP%}O>oe!IP%#A-|yG^_u`P?lBNH>4 zMFu%9C@cLGI-=VE#)nd33}tW2uyc&zici>i?EruHFajw;B{+g;qt-F6shKfJL>l^x?H=Wmnj z-5i69<7vE&&P9$m-~Vx?+iftp>!=YtwIx9AK5?g2cBKzNi{^F*j_nR{C`M37Jo^9H zL~g)2t!Qd%dy!BE_b``&rAJ{pIvz_nv=+YZmpyHpSVBM*9sFYS*)jT z46nP|jUz-D#dU7`BxIO*cH4g+C2H;x8@6$%E5ELEvBMInt$h!ayQ<S1ENh~jf{z6Z-!bkx}S#(#}-f@3uH zWxvsjVw%oh3PdBoIhSdo*`l$ny~q=FO8r<*Mkq92uC6Z0Xi-JNxl`UV;dDzzxQ6@s zC{1wU&S!5eZa6WYC`rES@tSc_KsdT-Vm54_F??BxA(w8BrSX*t^Lr;hoZYmZi~GEN zY({81m2&*u1KM9 zR(FfvLqQDMIm5iDZwzecK%}Ii&Z2%C40pz5lT%-RlDxVmG9uzC2y;nLky@Eqs)4#lb~I2hbl4s-9Q{{ofmQKYasuZI2WFCY|< zAS@w9i3K&Kp5U#Hs@v-NPNUuFh0LWiX*BuNpUUTi-!ZN7K@nbVEN2VwP=%!7|Fdr4 z;pUl&uh|ESt}v*}11PvEJN_>ESKk>fCUrolLI3(p(|g84POPobd&yxH2e^!CFW}Y< zV_Wa8A(`BwUmYi|g`?(vu#Dr`;cy<3&-(^NjR~kfQ-c#EDmPHS0j;n0bZLSE(|Q>_ z#*1X?eUR>@zkCGQ^!UKRs>|PB>~oB=X$Z;CVqSfSR?TqLjIrs`V{9K*KHGTH-UN&JL?hJ%&DX}-8X^#qo?=YfHJ{KH(xS1qc9|)*tv9GTU zC_p?+YGg`X)v3-NeIq$qj>jwfd&cBlGU?i<$J;vBQgUEThd2FXaIq7D&Vu>Vez*e69Rwq zL0vFAT(Lp&bod)4oOV8ZLoy|3%TyKHjXFbPzUMplbYqDF4NrcZTo{QuqAtjP!i zz{$qPR6Xkvvp)iw*6R^vyBb{wXJw1s(_IlJu*AW1YCsN=TjTp@Eg2c%We;GK_CcD5 z{&CXA9Q;RV>ZVTE`H+xrQ5`B49A&q|Ii*;K(8)b(Qy=-rjWjZk@HM)pEijTPI=@0j zPFd1>2J{}(B1&{Q>2xb$HzOu_h&N@0kJ*`fU4`kS`rbsYexea}+QKhOC;EtQWu>t)<11=|zR~m1 zcY9&8on7+wgJ?_g6>{pX(Ezt#NP|!i%I}S(IgKlbQmW70%OiN7Lkyq$Zw#JnWvD(e z$%<;Q$qcG}g(k8g7{4iXz-O#kx9$hKz2L+Zg2k1k-l4^)Md)f6%6e2jC$FYYlYLXz z{gxvn!Ww%NeNnjmKfB`=MQlt7rZ@0^SeZie#xnO2d4*^;eB>sHkIBRD%+`kK=vXZN zj&*nhpKVAV-Q0s8>;y$0ofq^g`<`WKq$LkM& z6cZy03{-O*&LHM*2(3z(Jj19d-v~*)_altKsM6oD3V2UbURS1%g7Z&n<{)SjM!_P2 z;5C$|&k#ZJDwfAdG2A3us02%U^7;a)NV}GhKJ&3HHa+#O%n-)BDWi7!L1)lMvp6Oi zY7hUF0!B95CNGv+M0hi-ZF0S;E{vuo7uOBoCoTkU=C*aMYx5!UCWjy$55 zwv?!^TwXW{Pj#+@u*hm^i?JC&4ETlyHa)!ukP+KH%ztGaIJeOw7hxxA=23EZ(x#*RW>HH6%9JLBUd!D((qjya*0edLyHE zR-ZU^c#c2w`Zg?9==ta;&<(Q2=IB%Xdv^PCj4eod@$=h#p$%ucb%S?n?&4;M?0HaD z&{vo$V7#@k?sV4;8q6o*^V}wbuEMl+uk-eMOH8dZ*#riyh9ka>s4pv)oZ}=Uljj*; zj-aWiv(@Gr4&?iZ@`HU7A`W0J%l1(+?!o$&&TKKd>N;oltzxH&8N#nlLJ!;gByZel z{ww~U`hC$l@E3~dSa;{Lpbh)O>nV>NSII}0f{n|*a~H1ubC+ya%h*2(gKq3AZug!I zlU|k8rSV61CRcB-`vxKaUZW?PmnRoBcH70FyI7WK+VY0;6c@a$cHKmW-b){C244S* zGeT5a9p$3uL@dxomSQs&&}DDqy$2!t%Mzw4DF zl^G_M|D0n|YYGF`uwf=us+!`fX0`U}b<%cAGHF)vpj#NF5QVR0nrqcM-(aQv#8b7F z&2jE;H&`i@uzy{nKtJy=?=Nq|6YyHg9$cy@q>!;|eN zba%^7{`~1MR`aN@`=?zkzvE%b39rV2ojEDt8%{?=7tNvGI3+{Wo+v;R)h{Mh1akvl zR#Xkfudhsh5%70B^TWR5k@c2t-TCO^QxA6fNe#T8P8+NYV6=_JofW&2k!Ie&cdBPb zC_gun`A(6EiZk_0*SFd$=7wmrNp&=vf%`#L_SLg5n16f1o`DZ_(?0|SrxG`Rh(zZE zE!G?3&_hJbx>Zqf5EN#x`x6)iaPZih_$7 zeIQ0xwYv#rd@Ng)jIr6*fEl*veHai((X9|R!>eYv{(?iG>W);;M3EYi)8-17uF4?c z{2hS|n;x9<5Nc1SvdvwJ*IgQh0MU=?~Fr*xY;!m%p7(Dxe%`Y^rd_98nGy7k{$O7i5ChN!NYiE3{raOn{h zMq+~4&u-H!qGPc=S33KAZ%xw5Zj&dv(T|`ziuB1m=YIV`)yvO9F-BgEh87v_HspDz zG`=CKwyV)I3;moI^HQ`|Bn!)Vp`a@VYZO5%MWs%?CX8cOqF(-cCWCuYjP?GKA!^Zz z_1M*(XCGGLT>>Z!D?WHE_|F)-82M;G|zJS`KJw^6m4L{%yxvqq%1!xdMH|JEh4J7iuJHop8MD3lcXEB{fGJ8hPi3i z4aV$w<9RE4{WZhO{c7%CC+v)Ou$^!5umA!r+-Rs{WD%bYij7Y=9K+llUTh%Xq_#0V zNX@1{ZKasm^D&?LV5Z7PjKEOsMA)*x51+oN!3foGp0H*?sI9xz#W8eIeqpQO6q1@` zf7*2{RwtzPDgyHIIl;G4hc7S*WJaQN(iy2{+t1UC2UWaKZ9N51#s_-3zwACf4}Q?S zx_(COBq_8uX%7>FZOQ;WE!AU~Zk}|Xnrx(Cl`s1Dg2pxCZID!Xl;iQd7Q!ku+~!Z4 zD-2gw`6K(`^ZM|mCh-w!841mhB?tA4-!mH>(PJY0cq7+Tnd-foj_IOlrJG$Ltde=^ zi|Da=uHeIBqX(8}4v7|ygeDjENMo2}i(d1J-G4lsF>5z%FaxNPd%k5P9-&jsQGKok z3tF#HRgktiLq#eDLRrqqTIIIN#L9`x2K{PnIMybUB^R@|9$kjc10@p6@km)-CRda@)}ZUn=J!ks z528rLZY9{q$cp86FcCA5W0gDGh%o_eyue(Z8}0L4tomR*qyMhRt zLyx_}JiZeh!pP=$EBn%fw_+9Ty4%xWx;xUf(F_}%Gt9Cu48IiXwu2|+GbD;D@`m$x zSg(ovKf7LgupSRK4#qR9KClf&whjGovlS1z1@WPedlM@4d)1# zbd8&fn8AZb!wJv##B?>b**$rw)P0qL@a6mPw8o68+egE%>do})Qwz!ZbIGqwC1Z5i zxMX43g4G?*4Q1UBgRfFO13pbzwEVXHfjj8OsP*ocwx-GVDbMTo{9XK+@YU^hL>n=` z7c(VM^{i>f27<|XtG~A~#f0gax8C!(u+3esPo=p>?Hrp`nXE~#_{_IS9=5JqS|@ds znz;L&gcn7en`nn`^v>9AcQxFqH+_B5ZBl5fTns^9P^R_R@qh4P6@=VJI<52Bh(&qs z&oeo8*gRp_3GWOmTf(lEd|9cz-68xb*naqSiwc)eWpHRH8nJL797J>P?DkbHM*T5K! z_55H9^zaf2?z@&}8hBFH99z#HsN=_@7f=p!(nyv*_kzEQUUaMeaDBgY9}dv<@0Ajl zY?Q=HLQ;0Ks#1F)(wJxJpX_SB*8GO$*CAtZ_2Tn2b_X`tS@hnhe`+baXQPv`BaBDy zkO`j56Jg%FjNlK}9HgfH?49U+IcK0L@f#igOVgYicf}P#QI;MABhwRBy=CJ-laJ#) zxHA`YinllCuLUHr$>ySSKk^f0ENUc1(c>o9e!T~A3qv7beF@K*B5qPu^Du_VxL_)vPU2kwoR z5;imth~K(hZNIH(o^^Oms=B()fo_A(E`C2{72n+qz5!7t_7LZr?=Zhgapp>)HQQE@ zqVYIt%KyyP?Q>_y&TRM*38M0Ig7bEz;CO!0@K8^!57Y^)py+S?;>h=VmkLVOtg)U z5`KZ_t+?ZKgv|TzN8y5 z!XfQo+(aXioqTs^p&3ZEBjxKUs!1Vs&fLY97uSWIj$!eXe2G3r4p+)Feb0Hpu74(I z5;ny7zB{hM5#i8#(2~>O!5DY_mufI+CduY%u5%l1MH%zI6Ps6Z-zVGxB4dlunIV1h z|IBu0j^d;g%9e)pa|bh5*#vwH3D>0m>HZaqStTCZon_U7H7Vn+y1r|hsGf1(2&Cn= z^~_j&3P!IK)G^hJri|EP-s>t*))K|nnpFBGQo5c5a>F&((#z*uZ|e?~z;8^-pU;V> zVHFB6jMk(WOwSdKTJk&gYZ-J!=yLn~xi9%sbeM$VA0S+8VL3CW4U?PB%U(6hTJl1r z&oz+Ef}to0+O++LUO~^{Huyfmq1(lj7+%8`tUFMtEmXv6&Z;n~)fL74>Wo?UvzeCk zE!qe=7WwNEo|-K4fC-2Bj|`lv^gxG6);_tp7P{G?soKg`GS8~O_G znKr7eDpmylS7KM!$Gp!oF-LKX4dKJ)-Tf=U>R;+p7UlN{hX-RsA+Iv=)LLR9cF4w7 zG6L|n!XK`wF+bakp-(E)TGBULIX-IR-k4@|DhKTjp1giLVpGz}lIH5&yy<}`M@^3{ zzPxnj(LJJ)2;Jq0G1^;NrX6<}DZYne7W2+lX++R{&QNUI!#=|xJF9xVrnpVvP`gzK z^Z7G6nmbG@{&TUxq467Yq)keaMj(UP6^mQtYs+9ZQx9UjJAa$D(Ld`QyB{6)(D|1k zLZs?erh(%N4<9Piy-Z&=^oXL9H(Vj%RL-#-W~g&G*X#kiVz?hd@>Y`XozFX~I?{W1 zajMJG2O0^wDO?uw3N)tR(W&=@eU>j`kDt8-W#RBK9!|6BPdGvh(a9Eor1n4pJA}BU zn0IME+;yS~(bfmP)!AmE@|w5_OJ;-qLg!EKd0kpRPHugM6*rDLDf8bA_}R~%&0Wkn zNl+0%SN2DFcs7GHmvcL054{cbF+omgFy$}T*&BU%A29i^YY4*UGK?$6Jz;H3pd+= zY#2(V;vZ_Lb?3Gs=d4O(wC6u%^xE^%in0DPRE`%DU81vD@sXlThp`%b6O~g^pbplk zHk~)Qc|$fz`foj>kjhnj1vo#*RUTsV`3sIOTHIeD3JXbvY=xKk83Zt%w>v|IMwVCR ziKz;VdHOyt)9K^(Oa>6c`seXj!=Vk>q8a^3tiA+BikIB`mpY^dU zBYdV_-IY8%L_bgP_Ds$2DqYkp`n6aONn~mH(ak37f47FSjESr8@aXiFIax5M?YFA9 zilV_>n1vxo4yy{rv*yhV#p(M??Un-C+(+ z7!CN>Zj?ZS7Y9P5jTxhRD2C5{zZG7wq2Blv_9%epaUSx~RYKtp$6;S?mVb9wv{G<8 zw4&6i7UT}-J7iB4tCO$&)+%3YVuQ2`rOKOtV3=-SZKXTkBQ^d?ww{^+uVGPJ+=PP`;HSV|47{Y9_d&OvB*FWSXegvbqEGm7q_f0C*Ko;2THObPHuI-JUi((=l6gCRE;oh<**-(kyTIj zL;!ik<(?cjN;6Q{GDqL^K4$K|Iqn-hQDg74c3G!JlybOd-}#U*|H?Ck!HG)#s5u1< z(2GMmfY@z6=aCuLQeLXxpTSa;aNcmO9POYzNXrCR99!h1s0{bJ@BYQygNSqW&vsuX z8U12baG}u-sG07vFeR=+K{>FaHpyMgdIrG8#w>dR4Sjx4y&DB?fTrTE)yDablO7Qb zx^=Dy)Njz25@yvsr|K2-l@hP4x9rQDGT4=3@Rb_sx?kx#Hqt8Fyc7Xg)TtlzefcCO zGq{rvIKi&$UMBZ=&~NL(5)S?*BlMb$36o*F;rq4hy(QlRY!nRO;{x~gg|=A9Z%Ci2 zW(1TZ?-Sgkk6sJAItNPr*rj8w^mWv|mmqbPOG>!VoS?7x%^srRK&_Yr$k_>r*L*fG z)bnt1{2Wu17FSTa7$NSdcsO^7LPbvh{uuUF(=yqUJ`2H&+<`m4uRWAlDjVfJ)u-6PR0NIYEne_|NDv?z4-cj3) zr1O91_s0-gJa`G>3MXTJ#XCuMRg3f#9KEGUIeF*OBA##pUgIo&*Le5cV@04-?k-j+}UN0l&h)~IoHi3)q#S}9X3ekg^x?`k7i z$_UK@5eQs0DQek)&&qnZM@(~MOEzD$R3nhqd=s||KEl1Y=kRT!kD1cz=Jn7)?i_Sf ze@K%PirTq)B>03V-KVAOWWPegT@&o=gMK+c!lOw)x0x{T{8Pj(!bKb*t*9>A^r%iT z6k)#w4npbd_mqB>4=~9}l4`T7f?X?2lb05G0Z!-GlA7JnHe&>9$5lID%yr2D>Wkh) zH?QIIi;*{8^5{1r7tHi|2b&TS;4vLkumzEjL51*-7Rb~6+k1;8?xgeYewS_MIx6Jm zsf+jGMf&;58(zN!v^%<}KNcJOW3sBAR2p>vphm0u~371QX@H zX3a;THI7f$9s8o2xLfG6n$l%#D0T(ncuDTRra6OcIOvgZIm;AY?O4=!JA?dz&V)4; zwUramE_{KLUNc&>$l|DgbS=AQ_n=NQl05}Ek=?Tng*Y1h7%wZRxwvBGA9qz3Lj`|Y z<6A0#YuL3G+?JWJPFK8rwYf~ZJU5FRteeOVgUi_4IcT;S=0pvO%ghxC%|^ex`r}N% z2@%-skII(h?vVr0E0593*)$?VP1_>@l6^1DpQ&OSRfO%1S_~d`FYbicIHePgU(tfW zf={;jRvXP|*-}zPFIr3ua}DwdnvLBbNlcoKJ1YQ`tE7H;{d&*rh%;No#__dB-(PyplI1fH-SMiWL z2c7S(^qaWmM@f4m=WrxAt2t{WnFo47rS{yCPj_V?`p zKrUcG;tTm+#bf+)m5zrbHragtO8%2Gmy>lq=8rt5_?_7$B6?tHm_Ns8!3Wg8OXUyLh;(nK zWRPoUH`|EJ^2z@DwH=5l6p&`t=ip?^C__Ovc~xk!*^#?0umtiJIA3k!KGZ&34_sOT zd4w5re}CHerm$Hc1m|6^7&&e`xZ$V~S}mh|Y3fcExK|v1A_>

I{*Wzx$vp2<6+N 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 U37iC>J%P8^(n?a55+?8e53_P`4gdfE literal 0 HcmV?d00001 diff --git a/mkdocs.yml b/mkdocs.yml index 8006eac4..ef746518 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,7 +94,6 @@ nav: - "Integrations + projects": integrations.md - "Release notes": releases.md - "Emojis 🥳 🎉": emojis.md - - "Template Functions": sprig.md - "Troubleshooting": troubleshooting.md - "Known issues": known-issues.md - "Deprecation notices": deprecations.md diff --git a/server/config.go b/server/config.go index c5560010..9c1c4e10 100644 --- a/server/config.go +++ b/server/config.go @@ -11,6 +11,8 @@ import ( // Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" + DefaultConfigFile = "/etc/ntfy/server.yml" + DefaultTemplateDir = "/etc/ntfy/templates" DefaultCacheDuration = 12 * time.Hour DefaultCacheBatchTimeout = time.Duration(0) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) @@ -173,7 +175,7 @@ type Config struct { // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - File: "", // Only used for testing + File: DefaultConfigFile, // Only used for testing BaseURL: "", ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", @@ -196,6 +198,7 @@ func NewConfig() *Config { AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + TemplateDir: DefaultTemplateDir, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, DisallowedTopics: DefaultDisallowedTopics, @@ -258,6 +261,5 @@ func NewConfig() *Config { WebPushEmailAddress: "", WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, - TemplateDir: "", } } diff --git a/server/server.yml b/server/server.yml index e1a58232..db968498 100644 --- a/server/server.yml +++ b/server/server.yml @@ -126,6 +126,26 @@ # attachment-file-size-limit: "15M" # attachment-expiry-duration: "3h" +# Template directory for message templates. +# +# When "X-Template: " (aliases: "Template: ", "Tpl: ") or "?template=" is set, transform the message +# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory. +# +# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys, +# which are interpreted as Go templates. +# +# Example template file (e.g. /etc/ntfy/templates/grafana.yml): +# title: | +# {{- if eq .status "firing" }} +# {{ .title | default "Alert firing" }} +# {{- else if eq .status "resolved" }} +# {{ .title | default "Alert resolved" }} +# {{- end }} +# message: | +# {{ .message | trunc 2000 }} +# +# template-dir: "/etc/ntfy/templates" + # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, # messages will additionally be sent out as e-mail using an external SMTP server. # diff --git a/server/server_test.go b/server/server_test.go index a783dbd2..c904cbb7 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2918,7 +2918,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) { require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) - require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com\n", m.Message) + require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message) } func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { @@ -2971,8 +2971,7 @@ Labels: Annotations: - summary = 15m load average too high Source: localhost:3000/alerting/grafana/NW9oDw-4z/view -Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter -`, m.Message) +Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message) } func TestServer_MessageTemplate_GitHub(t *testing.T) { @@ -3073,18 +3072,75 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { var ( //go:embed testdata/webhook_github_comment_created.json githubCommentCreatedJSON string + + //go:embed testdata/webhook_github_issue_opened.json + githubIssueOpenedJSON string ) -func TestServer_MessageTemplate_FromNamedTemplate(t *testing.T) { +func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) { t.Parallel() s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", githubCommentCreatedJSON, map[string]string{ - "Template": "github", - }) + response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) - require.Equal(t, "💬 New comment on issue #1389 — instant alerts without Pull to refresh", m.Title) - require.Equal(t, "💬 New comment on issue #1389 — instant alerts without Pull to refresh", m.Message) + require.Equal(t, "💬 [ntfy] New comment on issue #1389 instant alerts without Pull to refresh", m.Title) + require.Equal(t, `Commenter: https://github.com/wunter8 +Repository: https://github.com/binwiederhier/ntfy +Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289 + +Comment: +These are the things you need to do to get iOS push notifications to work: +1. open a browser to the web app of your ntfy instance and copy the URL (including "http://" or "https://", your domain or IP address, and any ports, and excluding any trailing slashes) +2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables +3. put the URL you copied in the default server URL setting in the iOS ntfy app +4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message) +} + +func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "🐛 [ntfy] Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) + require.Equal(t, `Opened by: https://github.com/TheUser-dev +Repository: https://github.com/binwiederhier/ntfy +Issue link: https://github.com/binwiederhier/ntfy/issues/1391 +Labels: 🪲 bug + +Description: +:lady_beetle: **Describe the bug** +When sending a notification (especially when it happens with multiple requests) this error occurs + +:computer: **Components impacted** +ntfy server 2.13.0 in docker, debian 12 arm64 + +:bulb: **Screenshots and/or logs** +`+"```"+` +closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:, visitor_ip=, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z) +`+"```"+` + +:crystal_ball: **Additional context** +Looks like this has already been fixed by #498, regression?`, m.Message) +} + +func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(` +title: | + Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }} +message: | + Custom message {{ .issue.number }} +`), 0644)) + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title) + require.Equal(t, "Custom message 1391", m.Message) } func newTestConfig(t *testing.T) *Config { @@ -3093,6 +3149,7 @@ func newTestConfig(t *testing.T) *Config { conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;" conf.AttachmentCacheDir = t.TempDir() + conf.TemplateDir = t.TempDir() return conf } diff --git a/server/templates/alertmanager.yml b/server/templates/alertmanager.yml new file mode 100644 index 00000000..803bbfcb --- /dev/null +++ b/server/templates/alertmanager.yml @@ -0,0 +1,29 @@ +title: | + {{- if eq .status "firing" }} + 🚨 Alert: {{ (first .alerts).labels.alertname }} + {{- else if eq .status "resolved" }} + ✅ Resolved: {{ (first .alerts).labels.alertname }} + {{- else }} + {{ fail "Unsupported Alertmanager status." }} + {{- end }} +message: | + Status: {{ .status | title }} + Receiver: {{ .receiver }} + + {{- range .alerts }} + Alert: {{ .labels.alertname }} + Instance: {{ .labels.instance }} + Severity: {{ .labels.severity }} + Starts at: {{ .startsAt }} + {{- if .endsAt }}Ends at: {{ .endsAt }}{{ end }} + {{- if .annotations.summary }} + Summary: {{ .annotations.summary }} + {{- end }} + {{- if .annotations.description }} + Description: {{ .annotations.description }} + {{- end }} + Source: {{ .generatorURL }} + + {{ end }} + + diff --git a/server/templates/github.yml b/server/templates/github.yml index 5d1b0b46..2c2922a2 100644 --- a/server/templates/github.yml +++ b/server/templates/github.yml @@ -6,7 +6,7 @@ title: | 👀 {{ .sender.login }} started watching {{ .repository.name }} {{- else if and .comment (eq .action "created") }} - 💬 New comment on #{{ .issue.number }}: {{ .issue.title }} + 💬 New comment on issue #{{ .issue.number }} {{ .issue.title }} {{- else if .pull_request }} 🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }} @@ -47,6 +47,7 @@ message: | {{ .action | title }} by: {{ .issue.user.html_url }} Repository: {{ .repository.html_url }} Issue link: {{ .issue.html_url }} + {{ if .issue.labels }}Labels: {{ range .issue.labels }}{{ .name }} {{ end }}{{ end }} {{ if .issue.body }} Description: {{ .issue.body | trunc 2000 }}{{ end }} diff --git a/server/templates/grafana.yml b/server/templates/grafana.yml index 42a16deb..658aa550 100644 --- a/server/templates/grafana.yml +++ b/server/templates/grafana.yml @@ -1,9 +1,11 @@ -message: | - {{if .alerts}} - {{.alerts | len}} alert(s) triggered - {{else}} - No alerts triggered. - {{end}} title: | - ⚠️ Grafana alert: {{.title}} + {{- if eq .status "firing" }} + 🚨 {{ .title | default "Alert firing" }} + {{- else if eq .status "resolved" }} + ✅ {{ .title | default "Alert resolved" }} + {{- else }} + ⚠️ Unknown alert: {{ .title | default "Alert" }} + {{- end }} +message: | + {{ .message | trunc 2000 }} diff --git a/server/testdata/webhook_alertmanager_firing.json b/server/testdata/webhook_alertmanager_firing.json new file mode 100644 index 00000000..9155bd9e --- /dev/null +++ b/server/testdata/webhook_alertmanager_firing.json @@ -0,0 +1,33 @@ +{ + "version": "4", + "groupKey": "...", + "status": "firing", + "receiver": "webhook-receiver", + "groupLabels": { + "alertname": "HighCPUUsage" + }, + "commonLabels": { + "alertname": "HighCPUUsage", + "instance": "server01", + "severity": "critical" + }, + "commonAnnotations": { + "summary": "High CPU usage detected" + }, + "alerts": [ + { + "status": "firing", + "labels": { + "alertname": "HighCPUUsage", + "instance": "server01", + "severity": "critical" + }, + "annotations": { + "summary": "High CPU usage detected" + }, + "startsAt": "2025-07-17T07:00:00Z", + "endsAt": "0001-01-01T00:00:00Z", + "generatorURL": "http://prometheus.local/graph?g0.expr=..." + } + ] +} diff --git a/server/testdata/webhook_grafana_resolved.json b/server/testdata/webhook_grafana_resolved.json new file mode 100644 index 00000000..41494578 --- /dev/null +++ b/server/testdata/webhook_grafana_resolved.json @@ -0,0 +1,51 @@ +{ + "receiver": "ntfy\\.example\\.com/alerts", + "status": "resolved", + "alerts": [ + { + "status": "resolved", + "labels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts", + "instance": "10.108.0.2:9100", + "job": "node-exporter" + }, + "annotations": { + "summary": "15m load average too high" + }, + "startsAt": "2024-03-15T02:28:00Z", + "endsAt": "2024-03-15T02:42:00Z", + "generatorURL": "localhost:3000/alerting/grafana/NW9oDw-4z/view", + "fingerprint": "becbfb94bd81ef48", + "silenceURL": "localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter", + "dashboardURL": "", + "panelURL": "", + "values": { + "B": 18.98211314475876, + "C": 0 + }, + "valueString": "[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]" + } + ], + "groupLabels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts" + }, + "commonLabels": { + "alertname": "Load avg 15m too high", + "grafana_folder": "Node alerts", + "instance": "10.108.0.2:9100", + "job": "node-exporter" + }, + "commonAnnotations": { + "summary": "15m load average too high" + }, + "externalURL": "localhost:3000/", + "version": "1", + "groupKey": "{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}", + "truncatedAlerts": 0, + "orgId": 1, + "title": "[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)", + "state": "ok", + "message": "**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\n" +} From 57df16dd62304adc725138df1e844bfb7201231a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 15:44:49 +0200 Subject: [PATCH 46/87] Remove UUID --- docs/publish.md | 1 - docs/publish/template-functions.md | 11 ----------- go.mod | 2 +- server/server_test.go | 4 ++-- util/sprig/crypto.go | 7 ------- util/sprig/crypto_test.go | 21 --------------------- util/sprig/functions.go | 8 +------- 7 files changed, 4 insertions(+), 50 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 6410bece..745c99c4 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1248,7 +1248,6 @@ Below are the functions that are available to use inside your message/title temp * [Path and Filepath Functions](publish/template-functions.md#path-and-filepath-functions): `base`, `dir`, `ext`, `clean`, `isAbs`, `osBase`, `osDir`, `osExt`, `osClean`, `osIsAbs` * [Flow Control Functions](publish/template-functions.md#flow-control-functions): `fail` * Advanced Functions - * [UUID Functions](publish/template-functions.md#uuid-functions): `uuidv4` * [Reflection](publish/template-functions.md#reflection-functions): `typeOf`, `kindIs`, `typeIsLike`, etc. * [Cryptographic and Security Functions](publish/template-functions.md#cryptographic-and-security-functions): `sha256sum`, etc. * [URL](publish/template-functions.md#url-functions): `urlParse`, `urlJoin` diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md index 238bddd9..4c6b6dfe 100644 --- a/docs/publish/template-functions.md +++ b/docs/publish/template-functions.md @@ -18,7 +18,6 @@ The original set of template functions is based on the [Sprig library](https://m - [Type Conversion Functions](#type-conversion-functions) - [Path and Filepath Functions](#path-and-filepath-functions) - [Flow Control Functions](#flow-control-functions) -- [UUID Functions](#uuid-functions) - [Reflection Functions](#reflection-functions) - [Cryptographic and Security Functions](#cryptographic-and-security-functions) - [URL Functions](#url-functions) @@ -1357,16 +1356,6 @@ template rendering should fail. fail "Please accept the end user license agreement" ``` -## UUID Functions - -Sprig can generate UUID v4 universally unique IDs. - -``` -uuidv4 -``` - -The above returns a new UUID of the v4 (randomly generated) type. - ## Reflection Functions Sprig provides rudimentary reflection tools. These help advanced template diff --git a/go.mod b/go.mod index 88b88463..dc35ae8b 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,6 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.16.1 github.com/SherClockHolmes/webpush-go v1.4.0 - github.com/google/uuid v1.6.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.22.0 github.com/stripe/stripe-go/v74 v74.30.0 @@ -69,6 +68,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/gorilla/css v1.0.1 // indirect diff --git a/server/server_test.go b/server/server_test.go index c904cbb7..ad2bb8fd 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3083,7 +3083,7 @@ func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testin response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) - require.Equal(t, "💬 [ntfy] New comment on issue #1389 instant alerts without Pull to refresh", m.Title) + require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title) require.Equal(t, `Commenter: https://github.com/wunter8 Repository: https://github.com/binwiederhier/ntfy Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289 @@ -3102,7 +3102,7 @@ func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) - require.Equal(t, "🐛 [ntfy] Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) + require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title) require.Equal(t, `Opened by: https://github.com/TheUser-dev Repository: https://github.com/binwiederhier/ntfy Issue link: https://github.com/binwiederhier/ntfy/issues/1391 diff --git a/util/sprig/crypto.go b/util/sprig/crypto.go index 4d027781..db8a6814 100644 --- a/util/sprig/crypto.go +++ b/util/sprig/crypto.go @@ -7,8 +7,6 @@ import ( "encoding/hex" "fmt" "hash/adler32" - - "github.com/google/uuid" ) func sha512sum(input string) string { @@ -30,8 +28,3 @@ func adler32sum(input string) string { hash := adler32.Checksum([]byte(input)) return fmt.Sprintf("%d", hash) } - -// uuidv4 provides a safe and secure UUID v4 implementation -func uuidv4() string { - return uuid.New().String() -} diff --git a/util/sprig/crypto_test.go b/util/sprig/crypto_test.go index bad809a5..d6fb1736 100644 --- a/util/sprig/crypto_test.go +++ b/util/sprig/crypto_test.go @@ -31,24 +31,3 @@ func TestAdler32Sum(t *testing.T) { t.Error(err) } } - -func TestUUIDGeneration(t *testing.T) { - tpl := `{{uuidv4}}` - out, err := runRaw(tpl, nil) - if err != nil { - t.Error(err) - } - - if len(out) != 36 { - t.Error("Expected UUID of length 36") - } - - out2, err := runRaw(tpl, nil) - if err != nil { - t.Error(err) - } - - if out == out2 { - t.Error("Expected subsequent UUID generations to be different") - } -} diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 1cd026c6..10ededd6 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -58,10 +58,7 @@ var genericMap = map[string]any{ }, "substr": substring, // Switch order so that "foo" | repeat 5 - "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, - // Deprecated: Use trimAll. - "trimall": func(a, b string) string { return strings.Trim(b, a) }, - // Switch order so that "$foo" | trimall "$" + "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, "trimAll": func(a, b string) string { return strings.Trim(b, a) }, "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, @@ -220,9 +217,6 @@ var genericMap = map[string]any{ "chunk": chunk, "mustChunk": mustChunk, - // UUIDs: - "uuidv4": uuidv4, - // Flow Control: "fail": func(msg string) (string, error) { return "", errors.New(msg) }, From dde07adbdc9c020a6c163a420f68121688d970a0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 16:46:53 +0200 Subject: [PATCH 47/87] Add some limits --- docs/publish.md | 15 ++++++++++----- server/errors.go | 1 - server/server.go | 6 ++++-- server/server_test.go | 36 ++++++++++++++++++++++++++++++++++++ util/sprig/defaults.go | 2 +- util/sprig/functions.go | 7 ++++++- util/sprig/numeric.go | 10 +++++++++- util/sprig/strings.go | 9 +++++++++ util/timeout_writer.go | 4 ++-- 9 files changed, 77 insertions(+), 13 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 745c99c4..dc124dbc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -969,15 +969,20 @@ To learn the basics of Go's templating language, please see [template syntax](#t ### Pre-defined templates When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the -message and/or title based on one of the built-in pre-defined templates +message and/or title based on one of the built-in pre-defined templates. The following **pre-defined templates** are available: -* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment) -* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts) -* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts) +* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml). +* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml). +* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml). -Here's an example of how to use the pre-defined `github` template: First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. +To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`, +can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. + +Here's an example of how to use the **pre-defined `github` template**: + +First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`.

![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 }
GitHub webhook configuration
diff --git a/server/errors.go b/server/errors.go index fa504410..c6745779 100644 --- a/server/errors.go +++ b/server/errors.go @@ -123,7 +123,6 @@ var ( errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} - errHTTPBadRequestTemplateDirectoryNotConfigured = &errHTTP{40046, http.StatusBadRequest, "invalid request: template directory not configured", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} diff --git a/server/server.go b/server/server.go index 7bad3fde..0d69e068 100644 --- a/server/server.go +++ b/server/server.go @@ -145,6 +145,7 @@ const ( unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks + templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks templateFileExtension = ".yml" // Template files must end with this extension ) @@ -1127,7 +1128,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return err } } - if len(m.Message) > s.config.MessageSizeLimit { + if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit { return errHTTPBadRequestTemplateMessageTooLarge } return nil @@ -1188,7 +1189,8 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) { return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } var buf bytes.Buffer - if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { + limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) + if err := t.Execute(limitWriter, data); err != nil { return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } return strings.TrimSpace(buf.String()), nil diff --git a/server/server_test.go b/server/server_test.go index ad2bb8fd..36bbae3f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3143,6 +3143,42 @@ message: | require.Equal(t, "Custom message 1391", m.Message) } +func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 9999 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template") +} + +func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 10001 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000") +} + +func TestServer_MessageTemplate_Until100_000(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 7dcf7450..71c3e61b 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -132,7 +132,7 @@ func toRawJSON(v any) string { if err != nil { panic(err) } - return string(output) + return output } // mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 10ededd6..c9b9f86b 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -14,6 +14,11 @@ import ( "time" ) +const ( + loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long + stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues +) + // TxtFuncMap produces the function map. // // Use this to pass the functions into the template engine: @@ -58,7 +63,7 @@ var genericMap = map[string]any{ }, "substr": substring, // Switch order so that "foo" | repeat 5 - "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, + "repeat": repeat, "trimAll": func(a, b string) string { return strings.Trim(b, a) }, "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go index e41f61f5..901fe3f3 100644 --- a/util/sprig/numeric.go +++ b/util/sprig/numeric.go @@ -127,7 +127,15 @@ func until(count int) []int { } func untilStep(start, stop, step int) []int { - v := []int{} + var v []int + if step == 0 { + return v + } + + iterations := math.Abs(float64(stop)-float64(start)) / float64(step) + if iterations > loopExecutionLimit { + panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations)) + } if stop < start { if step >= 0 { diff --git a/util/sprig/strings.go b/util/sprig/strings.go index 911aa6f4..11459a4b 100644 --- a/util/sprig/strings.go +++ b/util/sprig/strings.go @@ -187,3 +187,12 @@ func substring(start, end int, s string) string { } return s[start:end] } + +func repeat(count int, str string) string { + if count > loopExecutionLimit { + panic(fmt.Sprintf("repeat count %d exceeds limit of %d", count, loopExecutionLimit)) + } else if count*len(str) >= stringLengthLimit { + panic(fmt.Sprintf("repeat count %d with string length %d exceeds limit of %d", count, len(str), stringLengthLimit)) + } + return strings.Repeat(str, count) +} diff --git a/util/timeout_writer.go b/util/timeout_writer.go index 370068c4..d531916d 100644 --- a/util/timeout_writer.go +++ b/util/timeout_writer.go @@ -7,7 +7,7 @@ import ( ) // ErrWriteTimeout is returned when a write timed out -var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation") +var ErrWriteTimeout = errors.New("write operation failed due to timeout") // TimeoutWriter wraps an io.Writer that will time out after the given timeout type TimeoutWriter struct { @@ -28,7 +28,7 @@ func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter { // Write implements the io.Writer interface, failing if called after the timeout period from creation. func (tw *TimeoutWriter) Write(p []byte) (n int, err error) { if time.Since(tw.start) > tw.timeout { - return 0, errors.New("write operation failed due to timeout since creation") + return 0, ErrWriteTimeout } return tw.writer.Write(p) } From f0d5392e9e7c365d27217e9a6b4f8a6a4b09f8e0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 21:32:05 +0200 Subject: [PATCH 48/87] Self-review --- docs/releases.md | 3 ++- server/server.go | 25 +++++++++++++++---------- server/templates/alertmanager.yml | 2 -- server/templates/github.yml | 1 - server/templates/grafana.yml | 1 - 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index fe91f580..6171dcff 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1456,7 +1456,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* You can now use a subset of [Sprig](https://github.com/Masterminds/sprig) functions in message/title templates ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting and to [@wunter8](https://github.com/wunter8) for implementing) +* Enhanced JSON webhook support via [pre-defined](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) +* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/server/server.go b/server/server.go index 0d69e068..f3d2ac51 100644 --- a/server/server.go +++ b/server/server.go @@ -57,7 +57,7 @@ type Server struct { userManager *user.Manager // Might be nil! messageCache *messageCache // Database that stores the messages webPush *webPushStore // Database that stores web push subscriptions - fileCache *fileCache // Name system based cache that stores attachments + fileCache *fileCache // File system based cache that stores attachments stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set @@ -1120,11 +1120,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) if templateName := template.Name(); templateName != "" { - if err := s.replaceTemplateFromFile(m, templateName, peekedBody); err != nil { + if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil { return err } } else { - if err := s.replaceTemplateFromParams(m, peekedBody); err != nil { + if err := s.renderTemplateFromParams(m, peekedBody); err != nil { return err } } @@ -1134,7 +1134,9 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return nil } -func (s *Server) replaceTemplateFromFile(m *message, templateName, peekedBody string) error { +// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. +// The template file must be in the templates directory, or in the configured template directory. +func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error { if !templateNameRegex.MatchString(templateName) { return errHTTPBadRequestTemplateFileNotFound } @@ -1153,30 +1155,33 @@ func (s *Server) replaceTemplateFromFile(m *message, templateName, peekedBody st } var err error if tpl.Message != nil { - if m.Message, err = s.replaceTemplate(*tpl.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil { return err } } if tpl.Title != nil { - if m.Title, err = s.replaceTemplate(*tpl.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil { return err } } return nil } -func (s *Server) replaceTemplateFromParams(m *message, peekedBody string) error { +// renderTemplateFromParams transforms the JSON message body according to the inline template in the +// message and title parameters. +func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error { var err error - if m.Message, err = s.replaceTemplate(m.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil { return err } - if m.Title, err = s.replaceTemplate(m.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil { return err } return nil } -func (s *Server) replaceTemplate(tpl string, source string) (string, error) { +// renderTemplate renders a template with the given JSON source data. +func (s *Server) renderTemplate(tpl string, source string) (string, error) { if templateDisallowedRegex.MatchString(tpl) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } diff --git a/server/templates/alertmanager.yml b/server/templates/alertmanager.yml index 803bbfcb..a63a756c 100644 --- a/server/templates/alertmanager.yml +++ b/server/templates/alertmanager.yml @@ -25,5 +25,3 @@ message: | Source: {{ .generatorURL }} {{ end }} - - diff --git a/server/templates/github.yml b/server/templates/github.yml index 2c2922a2..aee95b42 100644 --- a/server/templates/github.yml +++ b/server/templates/github.yml @@ -55,4 +55,3 @@ message: | {{- else }} {{ fail "Unsupported GitHub event type or action." }} {{- end }} - diff --git a/server/templates/grafana.yml b/server/templates/grafana.yml index 658aa550..bdb64e45 100644 --- a/server/templates/grafana.yml +++ b/server/templates/grafana.yml @@ -8,4 +8,3 @@ title: | {{- end }} message: | {{ .message | trunc 2000 }} - From 8b4834929d3566960e8041eb8ec0592b5b974968 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 22:30:07 +0200 Subject: [PATCH 49/87] Clean code --- util/sprig/flow_control.go | 7 + util/sprig/functions.go | 410 +++++++++++++-------------- util/sprig/functions_windows_test.go | 28 -- util/sprig/numeric.go | 77 +++-- util/sprig/strings.go | 34 +++ 5 files changed, 287 insertions(+), 269 deletions(-) create mode 100644 util/sprig/flow_control.go delete mode 100644 util/sprig/functions_windows_test.go diff --git a/util/sprig/flow_control.go b/util/sprig/flow_control.go new file mode 100644 index 00000000..2bdf382c --- /dev/null +++ b/util/sprig/flow_control.go @@ -0,0 +1,7 @@ +package sprig + +import "errors" + +func fail(msg string) (string, error) { + return "", errors.New(msg) +} diff --git a/util/sprig/functions.go b/util/sprig/functions.go index c9b9f86b..27d52524 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -1,14 +1,9 @@ package sprig import ( - "errors" - "golang.org/x/text/cases" - "golang.org/x/text/language" - "math/rand" "path" "path/filepath" "reflect" - "strconv" "strings" "text/template" "time" @@ -27,220 +22,195 @@ const ( // // TxtFuncMap returns a 'text/template'.FuncMap func TxtFuncMap() template.FuncMap { - gfm := make(map[string]any, len(genericMap)) - for k, v := range genericMap { - gfm[k] = v + return map[string]any{ + // Date functions + "ago": dateAgo, + "date": date, + "date_in_zone": dateInZone, + "date_modify": dateModify, + "dateInZone": dateInZone, + "dateModify": dateModify, + "duration": duration, + "durationRound": durationRound, + "htmlDate": htmlDate, + "htmlDateInZone": htmlDateInZone, + "must_date_modify": mustDateModify, + "mustDateModify": mustDateModify, + "mustToDate": mustToDate, + "now": time.Now, + "toDate": toDate, + "unixEpoch": unixEpoch, + + // Strings + "trunc": trunc, + "trim": strings.TrimSpace, + "upper": strings.ToUpper, + "lower": strings.ToLower, + "title": title, + "substr": substring, + "repeat": repeat, + "trimAll": trimAll, + "trimPrefix": trimPrefix, + "trimSuffix": trimSuffix, + "contains": contains, + "hasPrefix": hasPrefix, + "hasSuffix": hasSuffix, + "quote": quote, + "squote": squote, + "cat": cat, + "indent": indent, + "nindent": nindent, + "replace": replace, + "plural": plural, + "sha1sum": sha1sum, + "sha256sum": sha256sum, + "sha512sum": sha512sum, + "adler32sum": adler32sum, + "toString": strval, + + // Wrap Atoi to stop errors. + "atoi": atoi, + "seq": seq, + "toDecimal": toDecimal, + "split": split, + "splitList": splitList, + "splitn": splitn, + "toStrings": strslice, + + "until": until, + "untilStep": untilStep, + + // Basic arithmetic + "add1": add1, + "add": add, + "sub": sub, + "div": div, + "mod": mod, + "mul": mul, + "randInt": randInt, + "biggest": maxAsInt64, + "max": maxAsInt64, + "min": minAsInt64, + "maxf": maxAsFloat64, + "minf": minAsFloat64, + "ceil": ceil, + "floor": floor, + "round": round, + + // string slices. Note that we reverse the order b/c that's better + // for template processing. + "join": join, + "sortAlpha": sortAlpha, + + // Defaults + "default": dfault, + "empty": empty, + "coalesce": coalesce, + "all": all, + "any": anyNonEmpty, + "compact": compact, + "mustCompact": mustCompact, + "fromJSON": fromJSON, + "toJSON": toJSON, + "toPrettyJSON": toPrettyJSON, + "toRawJSON": toRawJSON, + "mustFromJSON": mustFromJSON, + "mustToJSON": mustToJSON, + "mustToPrettyJSON": mustToPrettyJSON, + "mustToRawJSON": mustToRawJSON, + "ternary": ternary, + + // Reflection + "typeOf": typeOf, + "typeIs": typeIs, + "typeIsLike": typeIsLike, + "kindOf": kindOf, + "kindIs": kindIs, + "deepEqual": reflect.DeepEqual, + + // Paths + "base": path.Base, + "dir": path.Dir, + "clean": path.Clean, + "ext": path.Ext, + "isAbs": path.IsAbs, + + // Filepaths + "osBase": filepath.Base, + "osClean": filepath.Clean, + "osDir": filepath.Dir, + "osExt": filepath.Ext, + "osIsAbs": filepath.IsAbs, + + // Encoding + "b64enc": base64encode, + "b64dec": base64decode, + "b32enc": base32encode, + "b32dec": base32decode, + + // Data Structures + "tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable. + "list": list, + "dict": dict, + "get": get, + "set": set, + "unset": unset, + "hasKey": hasKey, + "pluck": pluck, + "keys": keys, + "pick": pick, + "omit": omit, + "values": values, + + "append": push, + "push": push, + "mustAppend": mustPush, + "mustPush": mustPush, + "prepend": prepend, + "mustPrepend": mustPrepend, + "first": first, + "mustFirst": mustFirst, + "rest": rest, + "mustRest": mustRest, + "last": last, + "mustLast": mustLast, + "initial": initial, + "mustInitial": mustInitial, + "reverse": reverse, + "mustReverse": mustReverse, + "uniq": uniq, + "mustUniq": mustUniq, + "without": without, + "mustWithout": mustWithout, + "has": has, + "mustHas": mustHas, + "slice": slice, + "mustSlice": mustSlice, + "concat": concat, + "dig": dig, + "chunk": chunk, + "mustChunk": mustChunk, + + // Flow Control + "fail": fail, + + // Regex + "regexMatch": regexMatch, + "mustRegexMatch": mustRegexMatch, + "regexFindAll": regexFindAll, + "mustRegexFindAll": mustRegexFindAll, + "regexFind": regexFind, + "mustRegexFind": mustRegexFind, + "regexReplaceAll": regexReplaceAll, + "mustRegexReplaceAll": mustRegexReplaceAll, + "regexReplaceAllLiteral": regexReplaceAllLiteral, + "mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral, + "regexSplit": regexSplit, + "mustRegexSplit": mustRegexSplit, + "regexQuoteMeta": regexQuoteMeta, + + // URLs + "urlParse": urlParse, + "urlJoin": urlJoin, } - return gfm -} - -var genericMap = map[string]any{ - // Date functions - "ago": dateAgo, - "date": date, - "date_in_zone": dateInZone, - "date_modify": dateModify, - "dateInZone": dateInZone, - "dateModify": dateModify, - "duration": duration, - "durationRound": durationRound, - "htmlDate": htmlDate, - "htmlDateInZone": htmlDateInZone, - "must_date_modify": mustDateModify, - "mustDateModify": mustDateModify, - "mustToDate": mustToDate, - "now": time.Now, - "toDate": toDate, - "unixEpoch": unixEpoch, - - // Strings - "trunc": trunc, - "trim": strings.TrimSpace, - "upper": strings.ToUpper, - "lower": strings.ToLower, - "title": func(s string) string { - return cases.Title(language.English).String(s) - }, - "substr": substring, - // Switch order so that "foo" | repeat 5 - "repeat": repeat, - "trimAll": func(a, b string) string { return strings.Trim(b, a) }, - "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, - "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, - // Switch order so that "foobar" | contains "foo" - "contains": func(substr string, str string) bool { return strings.Contains(str, substr) }, - "hasPrefix": func(substr string, str string) bool { return strings.HasPrefix(str, substr) }, - "hasSuffix": func(substr string, str string) bool { return strings.HasSuffix(str, substr) }, - "quote": quote, - "squote": squote, - "cat": cat, - "indent": indent, - "nindent": nindent, - "replace": replace, - "plural": plural, - "sha1sum": sha1sum, - "sha256sum": sha256sum, - "sha512sum": sha512sum, - "adler32sum": adler32sum, - "toString": strval, - - // Wrap Atoi to stop errors. - "atoi": func(a string) int { i, _ := strconv.Atoi(a); return i }, - "seq": seq, - "toDecimal": toDecimal, - - // split "/" foo/bar returns map[int]string{0: foo, 1: bar} - "split": split, - "splitList": func(sep, orig string) []string { return strings.Split(orig, sep) }, - // splitn "/" foo/bar/fuu returns map[int]string{0: foo, 1: bar/fuu} - "splitn": splitn, - "toStrings": strslice, - - "until": until, - "untilStep": untilStep, - - // VERY basic arithmetic. - "add1": func(i any) int64 { return toInt64(i) + 1 }, - "add": func(i ...any) int64 { - var a int64 = 0 - for _, b := range i { - a += toInt64(b) - } - return a - }, - "sub": func(a, b any) int64 { return toInt64(a) - toInt64(b) }, - "div": func(a, b any) int64 { return toInt64(a) / toInt64(b) }, - "mod": func(a, b any) int64 { return toInt64(a) % toInt64(b) }, - "mul": func(a any, v ...any) int64 { - val := toInt64(a) - for _, b := range v { - val = val * toInt64(b) - } - return val - }, - "randInt": func(min, max int) int { return rand.Intn(max-min) + min }, - "biggest": max, - "max": max, - "min": min, - "maxf": maxf, - "minf": minf, - "ceil": ceil, - "floor": floor, - "round": round, - - // string slices. Note that we reverse the order b/c that's better - // for template processing. - "join": join, - "sortAlpha": sortAlpha, - - // Defaults - "default": dfault, - "empty": empty, - "coalesce": coalesce, - "all": all, - "any": anyNonEmpty, - "compact": compact, - "mustCompact": mustCompact, - "fromJSON": fromJSON, - "toJSON": toJSON, - "toPrettyJSON": toPrettyJSON, - "toRawJSON": toRawJSON, - "mustFromJSON": mustFromJSON, - "mustToJSON": mustToJSON, - "mustToPrettyJSON": mustToPrettyJSON, - "mustToRawJSON": mustToRawJSON, - "ternary": ternary, - - // Reflection - "typeOf": typeOf, - "typeIs": typeIs, - "typeIsLike": typeIsLike, - "kindOf": kindOf, - "kindIs": kindIs, - "deepEqual": reflect.DeepEqual, - - // Paths: - "base": path.Base, - "dir": path.Dir, - "clean": path.Clean, - "ext": path.Ext, - "isAbs": path.IsAbs, - - // Filepaths: - "osBase": filepath.Base, - "osClean": filepath.Clean, - "osDir": filepath.Dir, - "osExt": filepath.Ext, - "osIsAbs": filepath.IsAbs, - - // Encoding: - "b64enc": base64encode, - "b64dec": base64decode, - "b32enc": base32encode, - "b32dec": base32decode, - - // Data Structures: - "tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable. - "list": list, - "dict": dict, - "get": get, - "set": set, - "unset": unset, - "hasKey": hasKey, - "pluck": pluck, - "keys": keys, - "pick": pick, - "omit": omit, - "values": values, - - "append": push, - "push": push, - "mustAppend": mustPush, - "mustPush": mustPush, - "prepend": prepend, - "mustPrepend": mustPrepend, - "first": first, - "mustFirst": mustFirst, - "rest": rest, - "mustRest": mustRest, - "last": last, - "mustLast": mustLast, - "initial": initial, - "mustInitial": mustInitial, - "reverse": reverse, - "mustReverse": mustReverse, - "uniq": uniq, - "mustUniq": mustUniq, - "without": without, - "mustWithout": mustWithout, - "has": has, - "mustHas": mustHas, - "slice": slice, - "mustSlice": mustSlice, - "concat": concat, - "dig": dig, - "chunk": chunk, - "mustChunk": mustChunk, - - // Flow Control: - "fail": func(msg string) (string, error) { return "", errors.New(msg) }, - - // Regex - "regexMatch": regexMatch, - "mustRegexMatch": mustRegexMatch, - "regexFindAll": regexFindAll, - "mustRegexFindAll": mustRegexFindAll, - "regexFind": regexFind, - "mustRegexFind": mustRegexFind, - "regexReplaceAll": regexReplaceAll, - "mustRegexReplaceAll": mustRegexReplaceAll, - "regexReplaceAllLiteral": regexReplaceAllLiteral, - "mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral, - "regexSplit": regexSplit, - "mustRegexSplit": mustRegexSplit, - "regexQuoteMeta": regexQuoteMeta, - - // URLs: - "urlParse": urlParse, - "urlJoin": urlJoin, } diff --git a/util/sprig/functions_windows_test.go b/util/sprig/functions_windows_test.go deleted file mode 100644 index 9d8bd0e5..00000000 --- a/util/sprig/functions_windows_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package sprig - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestOsBase(t *testing.T) { - assert.NoError(t, runt(`{{ osBase "C:\\foo\\bar" }}`, "bar")) -} - -func TestOsDir(t *testing.T) { - assert.NoError(t, runt(`{{ osDir "C:\\foo\\bar\\baz" }}`, "C:\\foo\\bar")) -} - -func TestOsIsAbs(t *testing.T) { - assert.NoError(t, runt(`{{ osIsAbs "C:\\foo" }}`, "true")) - assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false")) -} - -func TestOsClean(t *testing.T) { - assert.NoError(t, runt(`{{ osClean "C:\\foo\\..\\foo\\..\\bar" }}`, "C:\\bar")) -} - -func TestOsExt(t *testing.T) { - assert.NoError(t, runt(`{{ osExt "C:\\foo\\bar\\baz.txt" }}`, ".txt")) -} diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go index 901fe3f3..32466818 100644 --- a/util/sprig/numeric.go +++ b/util/sprig/numeric.go @@ -3,6 +3,7 @@ package sprig import ( "fmt" "math" + "math/rand" "reflect" "strconv" "strings" @@ -78,7 +79,43 @@ func toInt64(v any) int64 { } } -func max(a any, i ...any) int64 { +func add1(i any) int64 { + return toInt64(i) + 1 +} + +func add(i ...any) int64 { + var a int64 + for _, b := range i { + a += toInt64(b) + } + return a +} + +func sub(a, b any) int64 { + return toInt64(a) - toInt64(b) +} + +func div(a, b any) int64 { + return toInt64(a) / toInt64(b) +} + +func mod(a, b any) int64 { + return toInt64(a) % toInt64(b) +} + +func mul(a any, v ...any) int64 { + val := toInt64(a) + for _, b := range v { + val = val * toInt64(b) + } + return val +} + +func randInt(min, max int) int { + return rand.Intn(max-min) + min +} + +func maxAsInt64(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { bb := toInt64(b) @@ -89,16 +126,15 @@ func max(a any, i ...any) int64 { return aa } -func maxf(a any, i ...any) float64 { - aa := toFloat64(a) +func maxAsFloat64(a any, i ...any) float64 { + m := toFloat64(a) for _, b := range i { - bb := toFloat64(b) - aa = math.Max(aa, bb) + m = math.Max(m, toFloat64(b)) } - return aa + return m } -func min(a any, i ...any) int64 { +func minAsInt64(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { bb := toInt64(b) @@ -109,13 +145,12 @@ func min(a any, i ...any) int64 { return aa } -func minf(a any, i ...any) float64 { - aa := toFloat64(a) +func minAsFloat64(a any, i ...any) float64 { + m := toFloat64(a) for _, b := range i { - bb := toFloat64(b) - aa = math.Min(aa, bb) + m = math.Min(m, toFloat64(b)) } - return aa + return m } func until(count int) []int { @@ -131,12 +166,10 @@ func untilStep(start, stop, step int) []int { if step == 0 { return v } - iterations := math.Abs(float64(stop)-float64(start)) / float64(step) if iterations > loopExecutionLimit { panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations)) } - if stop < start { if step >= 0 { return v @@ -146,7 +179,6 @@ func untilStep(start, stop, step int) []int { } return v } - if step <= 0 { return v } @@ -157,13 +189,11 @@ func untilStep(start, stop, step int) []int { } func floor(a any) float64 { - aa := toFloat64(a) - return math.Floor(aa) + return math.Floor(toFloat64(a)) } func ceil(a any) float64 { - aa := toFloat64(a) - return math.Ceil(aa) + return math.Ceil(toFloat64(a)) } func round(a any, p int, rOpt ...float64) float64 { @@ -195,6 +225,11 @@ func toDecimal(v any) int64 { return result } +func atoi(a string) int { + i, _ := strconv.Atoi(a) + return i +} + func seq(params ...int) string { increment := 1 switch len(params) { @@ -231,6 +266,6 @@ func seq(params ...int) string { } } -func intArrayToString(slice []int, delimeter string) string { - return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimeter), "[]") +func intArrayToString(slice []int, delimiter string) string { + return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), "[]") } diff --git a/util/sprig/strings.go b/util/sprig/strings.go index 11459a4b..8a1bdc1b 100644 --- a/util/sprig/strings.go +++ b/util/sprig/strings.go @@ -4,6 +4,8 @@ import ( "encoding/base32" "encoding/base64" "fmt" + "golang.org/x/text/cases" + "golang.org/x/text/language" "reflect" "strconv" "strings" @@ -149,6 +151,10 @@ func trunc(c int, s string) string { return s } +func title(s string) string { + return cases.Title(language.English).String(s) +} + func join(sep string, v any) string { return strings.Join(strslice(v), sep) } @@ -162,6 +168,10 @@ func split(sep, orig string) map[string]string { return res } +func splitList(sep, orig string) []string { + return strings.Split(orig, sep) +} + func splitn(sep string, n int, orig string) map[string]string { parts := strings.SplitN(orig, sep, n) res := make(map[string]string, len(parts)) @@ -196,3 +206,27 @@ func repeat(count int, str string) string { } return strings.Repeat(str, count) } + +func trimAll(a, b string) string { + return strings.Trim(b, a) +} + +func trimPrefix(a, b string) string { + return strings.TrimPrefix(b, a) +} + +func trimSuffix(a, b string) string { + return strings.TrimSuffix(b, a) +} + +func contains(substr string, str string) bool { + return strings.Contains(str, substr) +} + +func hasPrefix(substr string, str string) bool { + return strings.HasPrefix(str, substr) +} + +func hasSuffix(substr string, str string) bool { + return strings.HasSuffix(str, substr) +} From 892e82ceb8363076a69b9809445ab4c9b712801a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 22:41:53 +0200 Subject: [PATCH 50/87] Remove underscore functions --- util/sprig/date_test.go | 4 ++-- util/sprig/functions.go | 29 +++++++++++++---------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/util/sprig/date_test.go b/util/sprig/date_test.go index 3ebfa2be..496822cf 100644 --- a/util/sprig/date_test.go +++ b/util/sprig/date_test.go @@ -52,7 +52,7 @@ func TestDateInZone(t *testing.T) { if err != nil { t.Error(err) } - tpl := `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "UTC" }}` + tpl := `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "UTC" }}` // Test time.Time input if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { @@ -86,7 +86,7 @@ func TestDateInZone(t *testing.T) { } // Test case of invalid timezone - tpl = `{{ date_in_zone "02 Jan 06 15:04 -0700" .Time "foobar" }}` + tpl = `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "foobar" }}` if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil { t.Error(err) } diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 27d52524..f7aabfc5 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -24,22 +24,19 @@ const ( func TxtFuncMap() template.FuncMap { return map[string]any{ // Date functions - "ago": dateAgo, - "date": date, - "date_in_zone": dateInZone, - "date_modify": dateModify, - "dateInZone": dateInZone, - "dateModify": dateModify, - "duration": duration, - "durationRound": durationRound, - "htmlDate": htmlDate, - "htmlDateInZone": htmlDateInZone, - "must_date_modify": mustDateModify, - "mustDateModify": mustDateModify, - "mustToDate": mustToDate, - "now": time.Now, - "toDate": toDate, - "unixEpoch": unixEpoch, + "ago": dateAgo, + "date": date, + "dateInZone": dateInZone, + "dateModify": dateModify, + "duration": duration, + "durationRound": durationRound, + "htmlDate": htmlDate, + "htmlDateInZone": htmlDateInZone, + "mustDateModify": mustDateModify, + "mustToDate": mustToDate, + "now": time.Now, + "toDate": toDate, + "unixEpoch": unixEpoch, // Strings "trunc": trunc, From 8783c86cd6efb2b4d4c8789013ea09fad209d439 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 22:45:41 +0200 Subject: [PATCH 51/87] Fix docs --- docs/publish/template-functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md index 4c6b6dfe..f20ae1fd 100644 --- a/docs/publish/template-functions.md +++ b/docs/publish/template-functions.md @@ -617,7 +617,7 @@ The `dateModify` takes a modification and a date and returns the timestamp. Subtract an hour and thirty minutes from the current time: ``` -now | date_modify "-1.5h" +now | dateModify "-1.5h" ``` If the modification format is wrong `dateModify` will return the date unmodified. `mustDateModify` will return an error otherwise. From 1f34c39eb04c5e60c30a88c44705cc41c37d2f2f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 22:52:08 +0200 Subject: [PATCH 52/87] Refactor a little --- util/sprig/date.go | 14 +++----------- util/sprig/defaults.go | 9 +++------ util/sprig/dict.go | 4 +--- util/sprig/functions.go | 2 +- util/sprig/list.go | 1 - 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/util/sprig/date.go b/util/sprig/date.go index 3fed04e9..f01dcf0b 100644 --- a/util/sprig/date.go +++ b/util/sprig/date.go @@ -38,12 +38,10 @@ func dateInZone(fmt string, date any, zone string) string { case int32: t = time.Unix(int64(date), 0) } - loc, err := time.LoadLocation(zone) if err != nil { loc, _ = time.LoadLocation("UTC") } - return t.In(loc).Format(fmt) } @@ -65,7 +63,6 @@ func mustDateModify(fmt string, date time.Time) (time.Time, error) { func dateAgo(date any) string { var t time.Time - switch date := date.(type) { default: t = time.Now() @@ -76,9 +73,7 @@ func dateAgo(date any) string { case int: t = time.Unix(int64(date), 0) } - // Drop resolution to seconds - duration := time.Since(t).Round(time.Second) - return duration.String() + return time.Since(t).Round(time.Second).String() } func duration(sec any) string { @@ -106,13 +101,10 @@ func durationRound(duration any) string { case time.Time: d = time.Since(duration) } - - u := uint64(d) - neg := d < 0 - if neg { + var u uint64 + if d < 0 { u = -u } - var ( year = uint64(time.Hour) * 24 * 365 month = uint64(time.Hour) * 24 * 30 diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 71c3e61b..948747b9 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -7,7 +7,7 @@ import ( "strings" ) -// dfault checks whether `given` is set, and returns default if not set. +// defaultValue checks whether `given` is set, and returns default if not set. // // This returns `d` if `given` appears not to be set, and `given` otherwise. // @@ -17,8 +17,7 @@ import ( // Structs are never considered unset. // // For everything else, including pointers, a nil value is unset. -func dfault(d any, given ...any) any { - +func defaultValue(d any, given ...any) any { if empty(given) || empty(given[0]) { return d } @@ -31,7 +30,6 @@ func empty(given any) bool { if !g.IsValid() { return true } - // Basically adapted from text/template.isTrue switch g.Kind() { default: @@ -140,8 +138,7 @@ func mustToRawJSON(v any) (string, error) { buf := new(bytes.Buffer) enc := json.NewEncoder(buf) enc.SetEscapeHTML(false) - err := enc.Encode(&v) - if err != nil { + if err := enc.Encode(&v); err != nil { return "", err } return strings.TrimSuffix(buf.String(), "\n"), nil diff --git a/util/sprig/dict.go b/util/sprig/dict.go index 97182a97..6485763e 100644 --- a/util/sprig/dict.go +++ b/util/sprig/dict.go @@ -33,7 +33,7 @@ func pluck(key string, d ...map[string]any) []any { } func keys(dicts ...map[string]any) []string { - k := []string{} + var k []string for _, dict := range dicts { for key := range dict { k = append(k, key) @@ -54,12 +54,10 @@ func pick(dict map[string]any, keys ...string) map[string]any { func omit(dict map[string]any, keys ...string) map[string]any { res := map[string]any{} - omit := make(map[string]bool, len(keys)) for _, k := range keys { omit[k] = true } - for k, v := range dict { if _, ok := omit[k]; !ok { res[k] = v diff --git a/util/sprig/functions.go b/util/sprig/functions.go index f7aabfc5..f0232a5b 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -100,7 +100,7 @@ func TxtFuncMap() template.FuncMap { "sortAlpha": sortAlpha, // Defaults - "default": dfault, + "default": defaultValue, "empty": empty, "coalesce": coalesce, "all": all, diff --git a/util/sprig/list.go b/util/sprig/list.go index 138ecfa5..d8882af0 100644 --- a/util/sprig/list.go +++ b/util/sprig/list.go @@ -20,7 +20,6 @@ func push(list any, v any) []any { if err != nil { panic(err) } - return l } From f4a74dac57d0a55e1ae28660cfac3997c20500db Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sat, 19 Jul 2025 21:51:00 -0600 Subject: [PATCH 53/87] doc corrections --- docs/publish.md | 5 +++-- docs/publish/template-functions.md | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index dc124dbc..2d347f22 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -954,8 +954,8 @@ is valid JSON). You can enable templating by setting the `X-Template` header (or its aliases `Template` or `tpl`, or the query parameter `?template=...`): -* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a template name (e.g. `?template=github`) - to a pre-defined template name (e.g. `github`, `grafana`, or `alertmanager`) will use the template with that name. +* **Pre-defined template files**: Setting the `X-Template` header or query parameter to a pre-defined template name (one of `github`, + `grafana`, or `alertmanager`, such as `?template=github`) will use the built-in template with that name. See [pre-defined templates](#pre-defined-templates) for more details. * **Custom template files**: Setting the `X-Template` header or query parameter to a custom template name (e.g. `?template=myapp`) will use a custom template file from the template directory (defaults to `/etc/ntfy/templates`, can be overridden with `template-dir`). @@ -1244,6 +1244,7 @@ Below are the functions that are available to use inside your message/title temp * [String List Functions](publish/template-functions.md#string-list-functions): `splitList`, `sortAlpha`, etc. * [Integer Math Functions](publish/template-functions.md#integer-math-functions): `add`, `max`, `mul`, etc. * [Integer List Functions](publish/template-functions.md#integer-list-functions): `until`, `untilStep` +* [Float Math Functions](publish/template-functions.md#float-math-functions): `maxf`, `minf` * [Date Functions](publish/template-functions.md#date-functions): `now`, `date`, etc. * [Defaults Functions](publish/template-functions.md#default-functions): `default`, `empty`, `coalesce`, `fromJSON`, `toJSON`, `toPrettyJSON`, `toRawJSON`, `ternary` * [Encoding Functions](publish/template-functions.md#encoding-functions): `b64enc`, `b64dec`, etc. diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md index f20ae1fd..a08e4717 100644 --- a/docs/publish/template-functions.md +++ b/docs/publish/template-functions.md @@ -10,6 +10,7 @@ The original set of template functions is based on the [Sprig library](https://m - [String List Functions](#string-list-functions) - [Integer Math Functions](#integer-math-functions) - [Integer List Functions](#integer-list-functions) +- [Float Math Functions](#float-math-functions) - [Date Functions](#date-functions) - [Default Functions](#default-functions) - [Encoding Functions](#encoding-functions) @@ -526,6 +527,28 @@ seq 0 2 10 => 0 2 4 6 8 10 seq 0 -2 -5 => 0 -2 -4 ``` +## Float Math Functions + +### maxf + +Return the largest of a series of floats: + +This will return `3`: + +``` +maxf 1 2.5 3 +``` + +### minf + +Return the smallest of a series of floats. + +This will return `1.5`: + +``` +minf 1.5 2 3 +``` + ## Date Functions ### now From 4d1baae6d0210bd6a5b60d8d0e7481a198c4a387 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 10:28:26 +0200 Subject: [PATCH 54/87] Refine --- docs/publish.md | 4 ++-- docs/publish/template-functions.md | 2 +- mkdocs.yml | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index dc124dbc..4e95932f 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1235,8 +1235,8 @@ A good way to experiment with Go templates is the **[Go Template Playground](htt your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)). ### Template functions -ntfy supports a subset of the [Sprig](https://github.com/Masterminds/sprig) template functions. This is useful for advanced -message templating and for transforming the data provided through the JSON payload. +ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), +thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload. Below are the functions that are available to use inside your message/title templates. diff --git a/docs/publish/template-functions.md b/docs/publish/template-functions.md index f20ae1fd..68892a5f 100644 --- a/docs/publish/template-functions.md +++ b/docs/publish/template-functions.md @@ -1,6 +1,6 @@ # Template Functions -These template functions may be used in the [message template](../publish.md#message-templating) feature of ntfy. Please refer to the examples in the documentation for how to use them. +These template functions may be used in the **[message template](../publish.md#message-templating)** feature of ntfy. Please refer to the examples in the documentation for how to use them. The original set of template functions is based on the [Sprig library](https://masterminds.github.io/sprig/). This documentation page is a (slightly modified) copy of their docs. **Thank you to the Sprig developers for their work!** 🙏 diff --git a/mkdocs.yml b/mkdocs.yml index ef746518..adaf166b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - "Integrations + projects": integrations.md - "Release notes": releases.md - "Emojis 🥳 🎉": emojis.md + - "Template functions": publish/template-functions.md - "Troubleshooting": troubleshooting.md - "Known issues": known-issues.md - "Deprecation notices": deprecations.md From 50c564d8a2c8a644cb418a522122935faa95506f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 11:24:58 +0200 Subject: [PATCH 55/87] AI docs --- util/sprig/crypto.go | 17 +++ util/sprig/date.go | 112 +++++++++++++-- util/sprig/date_test.go | 3 + util/sprig/defaults.go | 139 +++++++++++++++++-- util/sprig/dict.go | 118 ++++++++++++++++ util/sprig/flow_control.go | 1 + util/sprig/functions.go | 1 + util/sprig/functions_test.go | 2 +- util/sprig/list.go | 138 +++++++++++------- util/sprig/list_test.go | 3 + util/sprig/numeric.go | 238 +++++++++++++++++++++++++++++++- util/sprig/reflect.go | 42 ++++++ util/sprig/regex.go | 134 ++++++++++++++++++ util/sprig/strings.go | 261 ++++++++++++++++++++++++++++++++++- util/sprig/url.go | 1 - 15 files changed, 1132 insertions(+), 78 deletions(-) diff --git a/util/sprig/crypto.go b/util/sprig/crypto.go index db8a6814..da4bfc94 100644 --- a/util/sprig/crypto.go +++ b/util/sprig/crypto.go @@ -9,21 +9,38 @@ import ( "hash/adler32" ) +// sha512sum computes the SHA-512 hash of the input string and returns it as a hex-encoded string. +// This function can be used in templates to generate secure hashes of sensitive data. +// +// Example usage in templates: {{ "hello world" | sha512sum }} func sha512sum(input string) string { hash := sha512.Sum512([]byte(input)) return hex.EncodeToString(hash[:]) } +// sha256sum computes the SHA-256 hash of the input string and returns it as a hex-encoded string. +// This is a commonly used cryptographic hash function that produces a 256-bit (32-byte) hash value. +// +// Example usage in templates: {{ "hello world" | sha256sum }} func sha256sum(input string) string { hash := sha256.Sum256([]byte(input)) return hex.EncodeToString(hash[:]) } +// sha1sum computes the SHA-1 hash of the input string and returns it as a hex-encoded string. +// Note: SHA-1 is no longer considered secure against well-funded attackers for cryptographic purposes. +// Consider using sha256sum or sha512sum for security-critical applications. +// +// Example usage in templates: {{ "hello world" | sha1sum }} func sha1sum(input string) string { hash := sha1.Sum([]byte(input)) return hex.EncodeToString(hash[:]) } +// adler32sum computes the Adler-32 checksum of the input string and returns it as a decimal string. +// This is a non-cryptographic hash function primarily used for error detection. +// +// Example usage in templates: {{ "hello world" | adler32sum }} func adler32sum(input string) string { hash := adler32.Checksum([]byte(input)) return fmt.Sprintf("%d", hash) diff --git a/util/sprig/date.go b/util/sprig/date.go index f01dcf0b..3231e619 100644 --- a/util/sprig/date.go +++ b/util/sprig/date.go @@ -1,27 +1,61 @@ package sprig import ( + "math" "strconv" "time" ) -// Given a format and a date, format the date string. +// date formats a date according to the provided format string. // -// Date can be a `time.Time` or an `int, int32, int64`. -// In the later case, it is treated as seconds since UNIX -// epoch. +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05") +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ now | date "2006-01-02" }} func date(fmt string, date any) string { return dateInZone(fmt, date, "Local") } +// htmlDate formats a date in HTML5 date format (YYYY-MM-DD). +// +// Parameters: +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ now | htmlDate }} func htmlDate(date any) string { return dateInZone("2006-01-02", date, "Local") } +// htmlDateInZone formats a date in HTML5 date format (YYYY-MM-DD) in the specified timezone. +// +// Parameters: +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// - zone: Timezone name (e.g., "UTC", "America/New_York") +// +// If date is not one of the recognized types, the current time is used. +// If the timezone is invalid, UTC is used. +// +// Example usage in templates: {{ now | htmlDateInZone "UTC" }} func htmlDateInZone(date any, zone string) string { return dateInZone("2006-01-02", date, zone) } +// dateInZone formats a date according to the provided format string in the specified timezone. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05") +// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch) +// - zone: Timezone name (e.g., "UTC", "America/New_York") +// +// If date is not one of the recognized types, the current time is used. +// If the timezone is invalid, UTC is used. +// +// Example usage in templates: {{ now | dateInZone "2006-01-02 15:04:05" "UTC" }} func dateInZone(fmt string, date any, zone string) string { var t time.Time switch date := date.(type) { @@ -45,6 +79,15 @@ func dateInZone(fmt string, date any, zone string) string { return t.In(loc).Format(fmt) } +// dateModify modifies a date by adding a duration and returns the resulting time. +// +// Parameters: +// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s") +// - date: The time.Time to modify +// +// If the duration string is invalid, the original date is returned. +// +// Example usage in templates: {{ now | dateModify "-24h" }} func dateModify(fmt string, date time.Time) time.Time { d, err := time.ParseDuration(fmt) if err != nil { @@ -53,6 +96,15 @@ func dateModify(fmt string, date time.Time) time.Time { return date.Add(d) } +// mustDateModify modifies a date by adding a duration and returns the resulting time or an error. +// +// Parameters: +// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s") +// - date: The time.Time to modify +// +// Unlike dateModify, this function returns an error if the duration string is invalid. +// +// Example usage in templates: {{ now | mustDateModify "24h" }} func mustDateModify(fmt string, date time.Time) (time.Time, error) { d, err := time.ParseDuration(fmt) if err != nil { @@ -61,6 +113,14 @@ func mustDateModify(fmt string, date time.Time) (time.Time, error) { return date.Add(d), nil } +// dateAgo returns a string representing the time elapsed since the given date. +// +// Parameters: +// - date: Can be a time.Time, int, or int64 (seconds since UNIX epoch) +// +// If date is not one of the recognized types, the current time is used. +// +// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" | dateAgo }} func dateAgo(date any) string { var t time.Time switch date := date.(type) { @@ -76,6 +136,12 @@ func dateAgo(date any) string { return time.Since(t).Round(time.Second).String() } +// duration converts seconds to a duration string. +// +// Parameters: +// - sec: Can be a string (parsed as int64), or int64 representing seconds +// +// Example usage in templates: {{ 3600 | duration }} -> "1h0m0s" func duration(sec any) string { var n int64 switch value := sec.(type) { @@ -89,6 +155,15 @@ func duration(sec any) string { return (time.Duration(n) * time.Second).String() } +// durationRound formats a duration in a human-readable rounded format. +// +// Parameters: +// - duration: Can be a string (parsed as duration), int64 (nanoseconds), +// or time.Time (time since that moment) +// +// Returns a string with the largest appropriate unit (y, mo, d, h, m, s). +// +// Example usage in templates: {{ 3600 | duration | durationRound }} -> "1h" func durationRound(duration any) string { var d time.Duration switch duration := duration.(type) { @@ -101,10 +176,7 @@ func durationRound(duration any) string { case time.Time: d = time.Since(duration) } - var u uint64 - if d < 0 { - u = -u - } + u := uint64(math.Abs(float64(d))) var ( year = uint64(time.Hour) * 24 * 365 month = uint64(time.Hour) * 24 * 30 @@ -130,15 +202,39 @@ func durationRound(duration any) string { return "0s" } +// toDate parses a string into a time.Time using the specified format. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02") +// - str: The date string to parse +// +// If parsing fails, returns a zero time.Time. +// +// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" }} func toDate(fmt, str string) time.Time { t, _ := time.ParseInLocation(fmt, str, time.Local) return t } +// mustToDate parses a string into a time.Time using the specified format or returns an error. +// +// Parameters: +// - fmt: A Go time format string (e.g., "2006-01-02") +// - str: The date string to parse +// +// Unlike toDate, this function returns an error if parsing fails. +// +// Example usage in templates: {{ mustToDate "2006-01-02" "2023-01-01" }} func mustToDate(fmt, str string) (time.Time, error) { return time.ParseInLocation(fmt, str, time.Local) } +// unixEpoch returns the Unix timestamp (seconds since January 1, 1970 UTC) for the given time. +// +// Parameters: +// - date: A time.Time value +// +// Example usage in templates: {{ now | unixEpoch }} func unixEpoch(date time.Time) string { return strconv.FormatInt(date.Unix(), 10) } diff --git a/util/sprig/date_test.go b/util/sprig/date_test.go index 496822cf..ee9a9cc6 100644 --- a/util/sprig/date_test.go +++ b/util/sprig/date_test.go @@ -117,4 +117,7 @@ func TestDurationRound(t *testing.T) { if err := runtv(tpl, "3mo", map[string]any{"Time": "2400h5s"}); err != nil { t.Error(err) } + if err := runtv(tpl, "1m", map[string]any{"Time": "-1m1s"}); err != nil { + t.Error(err) + } } diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 948747b9..c5c14308 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -25,6 +25,21 @@ func defaultValue(d any, given ...any) any { } // empty returns true if the given value has the zero value for its type. +// This is a helper function used by defaultValue, coalesce, all, and anyNonEmpty. +// +// The following values are considered empty: +// - Invalid values +// - nil values +// - Zero-length arrays, slices, maps, and strings +// - Boolean false +// - Zero for all numeric types +// - Structs are never considered empty +// +// Parameters: +// - given: The value to check for emptiness +// +// Returns: +// - bool: True if the value is considered empty, false otherwise func empty(given any) bool { g := reflect.ValueOf(given) if !g.IsValid() { @@ -51,7 +66,16 @@ func empty(given any) bool { } } -// coalesce returns the first non-empty value. +// coalesce returns the first non-empty value from a list of values. +// If all values are empty, it returns nil. +// +// This is useful for providing a series of fallback values. +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - any: The first non-empty value, or nil if all values are empty func coalesce(v ...any) any { for _, val := range v { if !empty(val) { @@ -61,8 +85,15 @@ func coalesce(v ...any) any { return nil } -// all returns true if empty(x) is false for all values x in the list. -// If the list is empty, return true. +// all checks if all values in a list are non-empty. +// Returns true if every value in the list is non-empty. +// If the list is empty, returns true (vacuously true). +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - bool: True if all values are non-empty, false otherwise func all(v ...any) bool { for _, val := range v { if empty(val) { @@ -72,8 +103,15 @@ func all(v ...any) bool { return true } -// anyNonEmpty returns true if empty(x) is false for anyNonEmpty x in the list. -// If the list is empty, return false. +// anyNonEmpty checks if at least one value in a list is non-empty. +// Returns true if any value in the list is non-empty. +// If the list is empty, returns false. +// +// Parameters: +// - v: A variadic list of values to check +// +// Returns: +// - bool: True if at least one value is non-empty, false otherwise func anyNonEmpty(v ...any) bool { for _, val := range v { if !empty(val) { @@ -83,25 +121,58 @@ func anyNonEmpty(v ...any) bool { return false } -// fromJSON decodes JSON into a structured value, ignoring errors. +// fromJSON decodes a JSON string into a structured value. +// This function ignores any errors that occur during decoding. +// If the JSON is invalid, it returns nil. +// +// Parameters: +// - v: The JSON string to decode +// +// Returns: +// - any: The decoded value, or nil if decoding failed func fromJSON(v string) any { output, _ := mustFromJSON(v) return output } -// mustFromJSON decodes JSON into a structured value, returning errors. +// mustFromJSON decodes a JSON string into a structured value. +// Unlike fromJSON, this function returns any errors that occur during decoding. +// +// Parameters: +// - v: The JSON string to decode +// +// Returns: +// - any: The decoded value +// - error: Any error that occurred during decoding func mustFromJSON(v string) (any, error) { var output any err := json.Unmarshal([]byte(v), &output) return output, err } -// toJSON encodes an item into a JSON string +// toJSON encodes a value into a JSON string. +// This function ignores any errors that occur during encoding. +// If the value cannot be encoded, it returns an empty string. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value func toJSON(v any) string { output, _ := json.Marshal(v) return string(output) } +// mustToJSON encodes a value into a JSON string. +// Unlike toJSON, this function returns any errors that occur during encoding. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value +// - error: Any error that occurred during encoding func mustToJSON(v any) (string, error) { output, err := json.Marshal(v) if err != nil { @@ -110,12 +181,29 @@ func mustToJSON(v any) (string, error) { return string(output), nil } -// toPrettyJSON encodes an item into a pretty (indented) JSON string +// toPrettyJSON encodes a value into a pretty (indented) JSON string. +// This function ignores any errors that occur during encoding. +// If the value cannot be encoded, it returns an empty string. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The indented JSON string representation of the value func toPrettyJSON(v any) string { output, _ := json.MarshalIndent(v, "", " ") return string(output) } +// mustToPrettyJSON encodes a value into a pretty (indented) JSON string. +// Unlike toPrettyJSON, this function returns any errors that occur during encoding. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The indented JSON string representation of the value +// - error: Any error that occurred during encoding func mustToPrettyJSON(v any) (string, error) { output, err := json.MarshalIndent(v, "", " ") if err != nil { @@ -124,7 +212,15 @@ func mustToPrettyJSON(v any) (string, error) { return string(output), nil } -// toRawJSON encodes an item into a JSON string with no escaping of HTML characters. +// toRawJSON encodes a value into a JSON string with no escaping of HTML characters. +// This function panics if an error occurs during encoding. +// Unlike toJSON, HTML characters like <, >, and & are not escaped. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value without HTML escaping func toRawJSON(v any) string { output, err := mustToRawJSON(v) if err != nil { @@ -133,7 +229,16 @@ func toRawJSON(v any) string { return output } -// mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. +// mustToRawJSON encodes a value into a JSON string with no escaping of HTML characters. +// Unlike toRawJSON, this function returns any errors that occur during encoding. +// HTML characters like <, >, and & are not escaped in the output. +// +// Parameters: +// - v: The value to encode to JSON +// +// Returns: +// - string: The JSON string representation of the value without HTML escaping +// - error: Any error that occurred during encoding func mustToRawJSON(v any) (string, error) { buf := new(bytes.Buffer) enc := json.NewEncoder(buf) @@ -144,7 +249,17 @@ func mustToRawJSON(v any) (string, error) { return strings.TrimSuffix(buf.String(), "\n"), nil } -// ternary returns the first value if the last value is true, otherwise returns the second value. +// ternary implements a conditional (ternary) operator. +// It returns the first value if the condition is true, otherwise returns the second value. +// This is similar to the ?: operator in many programming languages. +// +// Parameters: +// - vt: The value to return if the condition is true +// - vf: The value to return if the condition is false +// - v: The boolean condition to evaluate +// +// Returns: +// - any: Either vt or vf depending on the value of v func ternary(vt any, vf any, v bool) any { if v { return vt diff --git a/util/sprig/dict.go b/util/sprig/dict.go index 6485763e..0a282add 100644 --- a/util/sprig/dict.go +++ b/util/sprig/dict.go @@ -1,5 +1,15 @@ package sprig +// get retrieves a value from a map by its key. +// If the key exists, returns the corresponding value. +// If the key doesn't exist, returns an empty string. +// +// Parameters: +// - d: The map to retrieve the value from +// - key: The key to look up +// +// Returns: +// - any: The value associated with the key, or an empty string if not found func get(d map[string]any, key string) any { if val, ok := d[key]; ok { return val @@ -7,21 +17,58 @@ func get(d map[string]any, key string) any { return "" } +// set adds or updates a key-value pair in a map. +// Modifies the map in place and returns the modified map. +// +// Parameters: +// - d: The map to modify +// - key: The key to set +// - value: The value to associate with the key +// +// Returns: +// - map[string]any: The modified map (same instance as the input map) func set(d map[string]any, key string, value any) map[string]any { d[key] = value return d } +// unset removes a key-value pair from a map. +// If the key doesn't exist, the map remains unchanged. +// Modifies the map in place and returns the modified map. +// +// Parameters: +// - d: The map to modify +// - key: The key to remove +// +// Returns: +// - map[string]any: The modified map (same instance as the input map) func unset(d map[string]any, key string) map[string]any { delete(d, key) return d } +// hasKey checks if a key exists in a map. +// +// Parameters: +// - d: The map to check +// - key: The key to look for +// +// Returns: +// - bool: True if the key exists in the map, false otherwise func hasKey(d map[string]any, key string) bool { _, ok := d[key] return ok } +// pluck extracts values for a specific key from multiple maps. +// Only includes values from maps where the key exists. +// +// Parameters: +// - key: The key to extract values for +// - d: A variadic list of maps to extract values from +// +// Returns: +// - []any: A slice containing all values associated with the key across all maps func pluck(key string, d ...map[string]any) []any { var res []any for _, dict := range d { @@ -32,6 +79,14 @@ func pluck(key string, d ...map[string]any) []any { return res } +// keys collects all keys from one or more maps. +// The returned slice may contain duplicate keys if multiple maps contain the same key. +// +// Parameters: +// - dicts: A variadic list of maps to collect keys from +// +// Returns: +// - []string: A slice containing all keys from all provided maps func keys(dicts ...map[string]any) []string { var k []string for _, dict := range dicts { @@ -42,6 +97,15 @@ func keys(dicts ...map[string]any) []string { return k } +// pick creates a new map containing only the specified keys from the original map. +// If a key doesn't exist in the original map, it won't be included in the result. +// +// Parameters: +// - dict: The source map +// - keys: A variadic list of keys to include in the result +// +// Returns: +// - map[string]any: A new map containing only the specified keys and their values func pick(dict map[string]any, keys ...string) map[string]any { res := map[string]any{} for _, k := range keys { @@ -52,6 +116,15 @@ func pick(dict map[string]any, keys ...string) map[string]any { return res } +// omit creates a new map excluding the specified keys from the original map. +// The original map remains unchanged. +// +// Parameters: +// - dict: The source map +// - keys: A variadic list of keys to exclude from the result +// +// Returns: +// - map[string]any: A new map containing all key-value pairs except those specified func omit(dict map[string]any, keys ...string) map[string]any { res := map[string]any{} omit := make(map[string]bool, len(keys)) @@ -66,6 +139,16 @@ func omit(dict map[string]any, keys ...string) map[string]any { return res } +// dict creates a new map from a list of key-value pairs. +// The arguments are treated as key-value pairs, where even-indexed arguments are keys +// and odd-indexed arguments are values. +// If there's an odd number of arguments, the last key will be assigned an empty string value. +// +// Parameters: +// - v: A variadic list of alternating keys and values +// +// Returns: +// - map[string]any: A new map containing the specified key-value pairs func dict(v ...any) map[string]any { dict := map[string]any{} lenv := len(v) @@ -80,6 +163,14 @@ func dict(v ...any) map[string]any { return dict } +// values collects all values from a map into a slice. +// The order of values in the resulting slice is not guaranteed. +// +// Parameters: +// - dict: The map to collect values from +// +// Returns: +// - []any: A slice containing all values from the map func values(dict map[string]any) []any { var values []any for _, value := range dict { @@ -88,6 +179,22 @@ func values(dict map[string]any) []any { return values } +// dig safely accesses nested values in maps using a sequence of keys. +// If any key in the path doesn't exist, it returns the default value. +// The function expects at least 3 arguments: one or more keys, a default value, and a map. +// +// Parameters: +// - ps: A variadic list where: +// - The first N-2 arguments are string keys forming the path +// - The second-to-last argument is the default value to return if the path doesn't exist +// - The last argument is the map to traverse +// +// Returns: +// - any: The value found at the specified path, or the default value if not found +// - error: Any error that occurred during traversal +// +// Panics: +// - If fewer than 3 arguments are provided func dig(ps ...any) (any, error) { if len(ps) < 3 { panic("dig needs at least three arguments") @@ -102,6 +209,17 @@ func dig(ps ...any) (any, error) { return digFromDict(dict, def, ks) } +// digFromDict is a helper function for dig that recursively traverses a map using a sequence of keys. +// If any key in the path doesn't exist, it returns the default value. +// +// Parameters: +// - dict: The map to traverse +// - d: The default value to return if the path doesn't exist +// - ks: A slice of string keys forming the path to traverse +// +// Returns: +// - any: The value found at the specified path, or the default value if not found +// - error: Any error that occurred during traversal func digFromDict(dict map[string]any, d any, ks []string) (any, error) { k, ns := ks[0], ks[1:] step, has := dict[k] diff --git a/util/sprig/flow_control.go b/util/sprig/flow_control.go index 2bdf382c..cfaa5081 100644 --- a/util/sprig/flow_control.go +++ b/util/sprig/flow_control.go @@ -2,6 +2,7 @@ package sprig import "errors" +// fail is a function that always returns an error with the given message. func fail(msg string) (string, error) { return "", errors.New(msg) } diff --git a/util/sprig/functions.go b/util/sprig/functions.go index f0232a5b..8dbb23f8 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -12,6 +12,7 @@ import ( const ( loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues + sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues ) // TxtFuncMap produces the function map. diff --git a/util/sprig/functions_test.go b/util/sprig/functions_test.go index e5989b98..4e83e993 100644 --- a/util/sprig/functions_test.go +++ b/util/sprig/functions_test.go @@ -52,7 +52,7 @@ func runtv(tpl, expect string, vars any) error { return err } if expect != b.String() { - return fmt.Errorf("Expected '%s', got '%s'", expect, b.String()) + return fmt.Errorf("expected '%s', got '%s'", expect, b.String()) } return nil } diff --git a/util/sprig/list.go b/util/sprig/list.go index d8882af0..fdcbf5e6 100644 --- a/util/sprig/list.go +++ b/util/sprig/list.go @@ -11,10 +11,15 @@ import ( // ints, and other types not implementing []any can be worked with. // For example, this is useful if you need to work on the output of regexs. +// list creates a new list (slice) containing the provided arguments. +// It accepts any number of arguments of any type and returns them as a slice. func list(v ...any) []any { return v } +// push appends an element to the end of a list (slice or array). +// It takes a list and a value, and returns a new list with the value appended. +// This function will panic if the first argument is not a slice or array. func push(list any, v any) []any { l, err := mustPush(list, v) if err != nil { @@ -23,99 +28,103 @@ func push(list any, v any) []any { return l } +// mustPush is the implementation of push that returns an error instead of panicking. +// It converts the input list to a slice of any type, then appends the value. func mustPush(list any, v any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() nl := make([]any, l) for i := 0; i < l; i++ { nl[i] = l2.Index(i).Interface() } - return append(nl, v), nil - default: return nil, fmt.Errorf("cannot push on type %s", tp) } } +// prepend adds an element to the beginning of a list (slice or array). +// It takes a list and a value, and returns a new list with the value at the start. +// This function will panic if the first argument is not a slice or array. func prepend(list any, v any) []any { l, err := mustPrepend(list, v) if err != nil { panic(err) } - return l } +// mustPrepend is the implementation of prepend that returns an error instead of panicking. +// It converts the input list to a slice of any type, then prepends the value. func mustPrepend(list any, v any) ([]any, error) { - //return append([]any{v}, list...) - tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() nl := make([]any, l) for i := 0; i < l; i++ { nl[i] = l2.Index(i).Interface() } - return append([]any{v}, nl...), nil - default: return nil, fmt.Errorf("cannot prepend on type %s", tp) } } +// chunk divides a list into sub-lists of the specified size. +// It takes a size and a list, and returns a list of lists, each containing +// up to 'size' elements from the original list. +// This function will panic if the second argument is not a slice or array. func chunk(size int, list any) [][]any { l, err := mustChunk(size, list) if err != nil { panic(err) } - return l } +// mustChunk is the implementation of chunk that returns an error instead of panicking. +// It divides the input list into chunks of the specified size. func mustChunk(size int, list any) ([][]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() - - cs := int(math.Floor(float64(l-1)/float64(size)) + 1) - nl := make([][]any, cs) - - for i := 0; i < cs; i++ { + numChunks := int(math.Floor(float64(l-1)/float64(size)) + 1) + if numChunks > sliceSizeLimit { + return nil, fmt.Errorf("number of chunks %d exceeds maximum limit of %d", numChunks, sliceSizeLimit) + } + result := make([][]any, numChunks) + for i := 0; i < numChunks; i++ { clen := size - if i == cs-1 { + // Handle the last chunk which might be smaller + if i == numChunks-1 { clen = int(math.Floor(math.Mod(float64(l), float64(size)))) if clen == 0 { clen = size } } - - nl[i] = make([]any, clen) - + result[i] = make([]any, clen) for j := 0; j < clen; j++ { ix := i*size + j - nl[i][j] = l2.Index(ix).Interface() + result[i][j] = l2.Index(ix).Interface() } } - - return nl, nil + return result, nil default: return nil, fmt.Errorf("cannot chunk type %s", tp) } } +// last returns the last element of a list (slice or array). +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. func last(list any) any { l, err := mustLast(list) if err != nil { @@ -125,6 +134,8 @@ func last(list any) any { return l } +// mustLast is the implementation of last that returns an error instead of panicking. +// It returns the last element of the list or nil if the list is empty. func mustLast(list any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { @@ -142,6 +153,9 @@ func mustLast(list any) (any, error) { } } +// first returns the first element of a list (slice or array). +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. func first(list any) any { l, err := mustFirst(list) if err != nil { @@ -151,6 +165,8 @@ func first(list any) any { return l } +// mustFirst is the implementation of first that returns an error instead of panicking. +// It returns the first element of the list or nil if the list is empty. func mustFirst(list any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { @@ -168,6 +184,9 @@ func mustFirst(list any) (any, error) { } } +// rest returns all elements of a list except the first one. +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. func rest(list any) []any { l, err := mustRest(list) if err != nil { @@ -177,28 +196,30 @@ func rest(list any) []any { return l } +// mustRest is the implementation of rest that returns an error instead of panicking. +// It returns all elements of the list except the first one, or nil if the list is empty. func mustRest(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() if l == 0 { return nil, nil } - nl := make([]any, l-1) for i := 1; i < l; i++ { nl[i-1] = l2.Index(i).Interface() } - return nl, nil default: return nil, fmt.Errorf("cannot find rest on type %s", tp) } } +// initial returns all elements of a list except the last one. +// If the list is empty, it returns nil. +// This function will panic if the argument is not a slice or array. func initial(list any) []any { l, err := mustInitial(list) if err != nil { @@ -208,28 +229,30 @@ func initial(list any) []any { return l } +// mustInitial is the implementation of initial that returns an error instead of panicking. +// It returns all elements of the list except the last one, or nil if the list is empty. func mustInitial(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() if l == 0 { return nil, nil } - nl := make([]any, l-1) for i := 0; i < l-1; i++ { nl[i] = l2.Index(i).Interface() } - return nl, nil default: return nil, fmt.Errorf("cannot find initial on type %s", tp) } } +// sortAlpha sorts a list of strings alphabetically. +// If the input is not a slice or array, it returns a single-element slice +// containing the string representation of the input. func sortAlpha(list any) []string { k := reflect.Indirect(reflect.ValueOf(list)).Kind() switch k { @@ -242,6 +265,8 @@ func sortAlpha(list any) []string { return []string{strval(list)} } +// reverse returns a new list with the elements in reverse order. +// This function will panic if the argument is not a slice or array. func reverse(v any) []any { l, err := mustReverse(v) if err != nil { @@ -251,42 +276,45 @@ func reverse(v any) []any { return l } +// mustReverse is the implementation of reverse that returns an error instead of panicking. +// It returns a new list with the elements in reverse order. func mustReverse(v any) ([]any, error) { tp := reflect.TypeOf(v).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(v) - l := l2.Len() // We do not sort in place because the incoming array should not be altered. nl := make([]any, l) for i := 0; i < l; i++ { nl[l-i-1] = l2.Index(i).Interface() } - return nl, nil default: return nil, fmt.Errorf("cannot find reverse on type %s", tp) } } +// compact returns a new list with all "empty" elements removed. +// An element is considered empty if it's nil, zero, an empty string, or an empty collection. +// This function will panic if the argument is not a slice or array. func compact(list any) []any { l, err := mustCompact(list) if err != nil { panic(err) } - return l } +// mustCompact is the implementation of compact that returns an error instead of panicking. +// It returns a new list with all "empty" elements removed. func mustCompact(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() - nl := []any{} + var nl []any var item any for i := 0; i < l; i++ { item = l2.Index(i).Interface() @@ -294,30 +322,32 @@ func mustCompact(list any) ([]any, error) { nl = append(nl, item) } } - return nl, nil default: return nil, fmt.Errorf("cannot compact on type %s", tp) } } +// uniq returns a new list with duplicate elements removed. +// The first occurrence of each element is kept. +// This function will panic if the argument is not a slice or array. func uniq(list any) []any { l, err := mustUniq(list) if err != nil { panic(err) } - return l } +// mustUniq is the implementation of uniq that returns an error instead of panicking. +// It returns a new list with duplicate elements removed. func mustUniq(list any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() - dest := []any{} + var dest []any var item any for i := 0; i < l; i++ { item = l2.Index(i).Interface() @@ -325,13 +355,15 @@ func mustUniq(list any) ([]any, error) { dest = append(dest, item) } } - return dest, nil default: return nil, fmt.Errorf("cannot find uniq on type %s", tp) } } +// inList checks if a value is present in a list. +// It uses deep equality comparison to check for matches. +// Returns true if the value is found, false otherwise. func inList(haystack []any, needle any) bool { for _, h := range haystack { if reflect.DeepEqual(needle, h) { @@ -341,21 +373,23 @@ func inList(haystack []any, needle any) bool { return false } +// without returns a new list with all occurrences of the specified values removed. +// This function will panic if the first argument is not a slice or array. func without(list any, omit ...any) []any { l, err := mustWithout(list, omit...) if err != nil { panic(err) } - return l } +// mustWithout is the implementation of without that returns an error instead of panicking. +// It returns a new list with all occurrences of the specified values removed. func mustWithout(list any, omit ...any) ([]any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() res := []any{} var item any @@ -365,22 +399,25 @@ func mustWithout(list any, omit ...any) ([]any, error) { res = append(res, item) } } - return res, nil default: return nil, fmt.Errorf("cannot find without on type %s", tp) } } +// has checks if a value is present in a list. +// Returns true if the value is found, false otherwise. +// This function will panic if the second argument is not a slice or array. func has(needle any, haystack any) bool { l, err := mustHas(needle, haystack) if err != nil { panic(err) } - return l } +// mustHas is the implementation of has that returns an error instead of panicking. +// It checks if a value is present in a list. func mustHas(needle any, haystack any) (bool, error) { if haystack == nil { return false, nil @@ -397,38 +434,41 @@ func mustHas(needle any, haystack any) (bool, error) { return true, nil } } - return false, nil default: return false, fmt.Errorf("cannot find has on type %s", tp) } } +// slice extracts a portion of a list based on the provided indices. +// Usage examples: // $list := [1, 2, 3, 4, 5] // slice $list -> list[0:5] = list[:] // slice $list 0 3 -> list[0:3] = list[:3] // slice $list 3 5 -> list[3:5] // slice $list 3 -> list[3:5] = list[3:] +// +// This function will panic if the first argument is not a slice or array. func slice(list any, indices ...any) any { l, err := mustSlice(list, indices...) if err != nil { panic(err) } - return l } +// mustSlice is the implementation of slice that returns an error instead of panicking. +// It extracts a portion of a list based on the provided indices. func mustSlice(list any, indices ...any) (any, error) { tp := reflect.TypeOf(list).Kind() switch tp { case reflect.Slice, reflect.Array: l2 := reflect.ValueOf(list) - l := l2.Len() if l == 0 { return nil, nil } - + // Determine start and end indices var start, end int if len(indices) > 0 { start = toInt(indices[0]) @@ -438,13 +478,15 @@ func mustSlice(list any, indices ...any) (any, error) { } else { end = toInt(indices[1]) } - return l2.Slice(start, end).Interface(), nil default: return nil, fmt.Errorf("list should be type of slice or array but %s", tp) } } +// concat combines multiple lists into a single list. +// It takes any number of lists and returns a new list containing all elements. +// This function will panic if any argument is not a slice or array. func concat(lists ...any) any { var res []any for _, list := range lists { diff --git a/util/sprig/list_test.go b/util/sprig/list_test.go index ec4c4c14..e6693b2f 100644 --- a/util/sprig/list_test.go +++ b/util/sprig/list_test.go @@ -1,6 +1,7 @@ package sprig import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -68,6 +69,8 @@ func TestMustChunk(t *testing.T) { for tpl, expect := range tests { assert.NoError(t, runt(tpl, expect)) } + err := runt(`{{ tuple `+strings.Repeat(" 0", 10001)+` | mustChunk 1 }}`, "a") + assert.ErrorContains(t, err, "number of chunks 10001 exceeds maximum limit of 10000") } func TestPrepend(t *testing.T) { diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go index 32466818..7ee3616d 100644 --- a/util/sprig/numeric.go +++ b/util/sprig/numeric.go @@ -9,7 +9,20 @@ import ( "strings" ) -// toFloat64 converts 64-bit floats +// toFloat64 converts a value to a 64-bit float. +// It handles various input types: +// - string: parsed as a float, returns 0 if parsing fails +// - integer types: converted to float64 +// - unsigned integer types: converted to float64 +// - float types: returned as is +// - bool: true becomes 1.0, false becomes 0.0 +// - other types: returns 0.0 +// +// Parameters: +// - v: The value to convert to float64 +// +// Returns: +// - float64: The converted value func toFloat64(v any) float64 { if str, ok := v.(string); ok { iv, err := strconv.ParseFloat(str, 64) @@ -39,12 +52,27 @@ func toFloat64(v any) float64 { } } +// toInt converts a value to a 32-bit integer. +// This is a wrapper around toInt64 that casts the result to int. +// +// Parameters: +// - v: The value to convert to int +// +// Returns: +// - int: The converted value func toInt(v any) int { // It's not optimal. But I don't want duplicate toInt64 code. return int(toInt64(v)) } -// toInt64 converts integer types to 64-bit integers +// toInt64 converts a value to a 64-bit integer. +// It handles various input types: +// - string: parsed as an integer, returns 0 if parsing fails +// - integer types: converted to int64 +// - unsigned integer types: converted to int64 (values > MaxInt64 become MaxInt64) +// - float types: truncated to int64 +// - bool: true becomes 1, false becomes 0 +// - other types: returns 0 func toInt64(v any) int64 { if str, ok := v.(string); ok { iv, err := strconv.ParseInt(str, 10, 64) @@ -53,7 +81,6 @@ func toInt64(v any) int64 { } return iv } - val := reflect.Indirect(reflect.ValueOf(v)) switch val.Kind() { case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int: @@ -79,10 +106,26 @@ func toInt64(v any) int64 { } } +// add1 increments a value by 1. +// The input is first converted to int64 using toInt64. +// +// Parameters: +// - i: The value to increment +// +// Returns: +// - int64: The incremented value func add1(i any) int64 { return toInt64(i) + 1 } +// add sums all the provided values. +// All inputs are converted to int64 using toInt64 before addition. +// +// Parameters: +// - i: A variadic list of values to sum +// +// Returns: +// - int64: The sum of all values func add(i ...any) int64 { var a int64 for _, b := range i { @@ -91,18 +134,61 @@ func add(i ...any) int64 { return a } +// sub subtracts the second value from the first. +// Both inputs are converted to int64 using toInt64 before subtraction. +// +// Parameters: +// - a: The value to subtract from +// - b: The value to subtract +// +// Returns: +// - int64: The result of a - b func sub(a, b any) int64 { return toInt64(a) - toInt64(b) } +// div divides the first value by the second. +// Both inputs are converted to int64 using toInt64 before division. +// Note: This performs integer division, so the result is truncated. +// +// Parameters: +// - a: The dividend +// - b: The divisor +// +// Returns: +// - int64: The result of a / b +// +// Panics: +// - If b evaluates to 0 (division by zero) func div(a, b any) int64 { return toInt64(a) / toInt64(b) } +// mod returns the remainder of dividing the first value by the second. +// Both inputs are converted to int64 using toInt64 before the modulo operation. +// +// Parameters: +// - a: The dividend +// - b: The divisor +// +// Returns: +// - int64: The remainder of a / b +// +// Panics: +// - If b evaluates to 0 (modulo by zero) func mod(a, b any) int64 { return toInt64(a) % toInt64(b) } +// mul multiplies all the provided values. +// All inputs are converted to int64 using toInt64 before multiplication. +// +// Parameters: +// - a: The first value to multiply +// - v: Additional values to multiply with a +// +// Returns: +// - int64: The product of all values func mul(a any, v ...any) int64 { val := toInt64(a) for _, b := range v { @@ -111,10 +197,30 @@ func mul(a any, v ...any) int64 { return val } +// randInt generates a random integer between min (inclusive) and max (exclusive). +// +// Parameters: +// - min: The lower bound (inclusive) +// - max: The upper bound (exclusive) +// +// Returns: +// - int: A random integer in the range [min, max) +// +// Panics: +// - If max <= min (via rand.Intn) func randInt(min, max int) int { return rand.Intn(max-min) + min } +// maxAsInt64 returns the maximum value from a list of values as an int64. +// All inputs are converted to int64 using toInt64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - int64: The maximum value from all inputs func maxAsInt64(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { @@ -126,6 +232,15 @@ func maxAsInt64(a any, i ...any) int64 { return aa } +// maxAsFloat64 returns the maximum value from a list of values as a float64. +// All inputs are converted to float64 using toFloat64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - float64: The maximum value from all inputs func maxAsFloat64(a any, i ...any) float64 { m := toFloat64(a) for _, b := range i { @@ -134,6 +249,15 @@ func maxAsFloat64(a any, i ...any) float64 { return m } +// minAsInt64 returns the minimum value from a list of values as an int64. +// All inputs are converted to int64 using toInt64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - int64: The minimum value from all inputs func minAsInt64(a any, i ...any) int64 { aa := toInt64(a) for _, b := range i { @@ -145,6 +269,15 @@ func minAsInt64(a any, i ...any) int64 { return aa } +// minAsFloat64 returns the minimum value from a list of values as a float64. +// All inputs are converted to float64 using toFloat64 before comparison. +// +// Parameters: +// - a: The first value to compare +// - i: Additional values to compare +// +// Returns: +// - float64: The minimum value from all inputs func minAsFloat64(a any, i ...any) float64 { m := toFloat64(a) for _, b := range i { @@ -153,6 +286,14 @@ func minAsFloat64(a any, i ...any) float64 { return m } +// until generates a sequence of integers from 0 to count (exclusive). +// If count is negative, it generates a sequence from 0 to count (inclusive) with step -1. +// +// Parameters: +// - count: The end value (exclusive if positive, inclusive if negative) +// +// Returns: +// - []int: A slice containing the generated sequence func until(count int) []int { step := 1 if count < 0 { @@ -161,6 +302,23 @@ func until(count int) []int { return untilStep(0, count, step) } +// untilStep generates a sequence of integers from start to stop with the specified step. +// The sequence is generated as follows: +// - If step is 0, returns an empty slice +// - If stop < start and step < 0, generates a decreasing sequence from start to stop (exclusive) +// - If stop > start and step > 0, generates an increasing sequence from start to stop (exclusive) +// - Otherwise, returns an empty slice +// +// Parameters: +// - start: The starting value (inclusive) +// - stop: The ending value (exclusive) +// - step: The increment between values +// +// Returns: +// - []int: A slice containing the generated sequence +// +// Panics: +// - If the number of iterations would exceed loopExecutionLimit func untilStep(start, stop, step int) []int { var v []int if step == 0 { @@ -188,14 +346,44 @@ func untilStep(start, stop, step int) []int { return v } +// floor returns the greatest integer value less than or equal to the input. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to floor +// +// Returns: +// - float64: The greatest integer value less than or equal to a func floor(a any) float64 { return math.Floor(toFloat64(a)) } +// ceil returns the least integer value greater than or equal to the input. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to ceil +// +// Returns: +// - float64: The least integer value greater than or equal to a func ceil(a any) float64 { return math.Ceil(toFloat64(a)) } +// round rounds a number to a specified number of decimal places. +// The input is first converted to float64 using toFloat64. +// +// Parameters: +// - a: The value to round +// - p: The number of decimal places to round to +// - rOpt: Optional rounding threshold (default is 0.5) +// +// Returns: +// - float64: The rounded value +// +// Examples: +// - round(3.14159, 2) returns 3.14 +// - round(3.14159, 2, 0.6) returns 3.14 (only rounds up if fraction ≥ 0.6) func round(a any, p int, rOpt ...float64) float64 { roundOn := .5 if len(rOpt) > 0 { @@ -203,7 +391,6 @@ func round(a any, p int, rOpt ...float64) float64 { } val := toFloat64(a) places := toFloat64(p) - var round float64 pow := math.Pow(10, places) digit := pow * val @@ -216,7 +403,15 @@ func round(a any, p int, rOpt ...float64) float64 { return round / pow } -// converts unix octal to decimal +// toDecimal converts a value from octal to decimal. +// The input is first converted to a string using fmt.Sprint, then parsed as an octal number. +// If the parsing fails, it returns 0. +// +// Parameters: +// - v: The octal value to convert +// +// Returns: +// - int64: The decimal representation of the octal value func toDecimal(v any) int64 { result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64) if err != nil { @@ -225,11 +420,34 @@ func toDecimal(v any) int64 { return result } +// atoi converts a string to an integer. +// If the conversion fails, it returns 0. +// +// Parameters: +// - a: The string to convert +// +// Returns: +// - int: The integer value of the string func atoi(a string) int { i, _ := strconv.Atoi(a) return i } +// seq generates a sequence of integers and returns them as a space-delimited string. +// The behavior depends on the number of parameters: +// - 0 params: Returns an empty string +// - 1 param: Generates sequence from 1 to param[0] +// - 2 params: Generates sequence from param[0] to param[1] +// - 3 params: Generates sequence from param[0] to param[2] with step param[1] +// +// If the end is less than the start, the sequence will be decreasing unless +// a positive step is explicitly provided (which would result in an empty string). +// +// Parameters: +// - params: Variable number of integers defining the sequence +// +// Returns: +// - string: A space-delimited string of the generated sequence func seq(params ...int) string { increment := 1 switch len(params) { @@ -266,6 +484,16 @@ func seq(params ...int) string { } } +// intArrayToString converts a slice of integers to a space-delimited string. +// The function removes the square brackets that would normally appear when +// converting a slice to a string. +// +// Parameters: +// - slice: The slice of integers to convert +// - delimiter: The delimiter to use between elements +// +// Returns: +// - string: A delimited string representation of the integer slice func intArrayToString(slice []int, delimiter string) string { return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), "[]") } diff --git a/util/sprig/reflect.go b/util/sprig/reflect.go index 5e37f64f..6315a780 100644 --- a/util/sprig/reflect.go +++ b/util/sprig/reflect.go @@ -6,23 +6,65 @@ import ( ) // typeIs returns true if the src is the type named in target. +// It compares the type name of src with the target string. +// +// Parameters: +// - target: The type name to check against +// - src: The value whose type will be checked +// +// Returns: +// - bool: True if the type name of src matches target, false otherwise func typeIs(target string, src any) bool { return target == typeOf(src) } +// typeIsLike returns true if the src is the type named in target or a pointer to that type. +// This is useful when you need to check for both a type and a pointer to that type. +// +// Parameters: +// - target: The type name to check against +// - src: The value whose type will be checked +// +// Returns: +// - bool: True if the type of src matches target or "*"+target, false otherwise func typeIsLike(target string, src any) bool { t := typeOf(src) return target == t || "*"+target == t } +// typeOf returns the type of a value as a string. +// It uses fmt.Sprintf with the %T format verb to get the type name. +// +// Parameters: +// - src: The value whose type name will be returned +// +// Returns: +// - string: The type name of src func typeOf(src any) string { return fmt.Sprintf("%T", src) } +// kindIs returns true if the kind of src matches the target kind. +// This checks the underlying kind (e.g., "string", "int", "map") rather than the specific type. +// +// Parameters: +// - target: The kind name to check against +// - src: The value whose kind will be checked +// +// Returns: +// - bool: True if the kind of src matches target, false otherwise func kindIs(target string, src any) bool { return target == kindOf(src) } +// kindOf returns the kind of a value as a string. +// The kind represents the specific Go type category (e.g., "string", "int", "map", "slice"). +// +// Parameters: +// - src: The value whose kind will be returned +// +// Returns: +// - string: The kind of src as a string func kindOf(src any) string { return reflect.ValueOf(src).Kind().String() } diff --git a/util/sprig/regex.go b/util/sprig/regex.go index fab55101..9853d2e1 100644 --- a/util/sprig/regex.go +++ b/util/sprig/regex.go @@ -4,20 +4,60 @@ import ( "regexp" ) +// regexMatch checks if a string matches a regular expression pattern. +// It ignores any errors that might occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to match against +// - s: The string to check +// +// Returns: +// - bool: True if the string matches the pattern, false otherwise func regexMatch(regex string, s string) bool { match, _ := regexp.MatchString(regex, s) return match } +// mustRegexMatch checks if a string matches a regular expression pattern. +// Unlike regexMatch, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to match against +// - s: The string to check +// +// Returns: +// - bool: True if the string matches the pattern, false otherwise +// - error: Any error that occurred during regex compilation func mustRegexMatch(regex string, s string) (bool, error) { return regexp.MatchString(regex, s) } +// regexFindAll finds all matches of a regular expression in a string. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - n: The maximum number of matches to return (negative means all matches) +// +// Returns: +// - []string: A slice containing all matched substrings func regexFindAll(regex string, s string, n int) []string { r := regexp.MustCompile(regex) return r.FindAllString(s, n) } +// mustRegexFindAll finds all matches of a regular expression in a string. +// Unlike regexFindAll, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - n: The maximum number of matches to return (negative means all matches) +// +// Returns: +// - []string: A slice containing all matched substrings +// - error: Any error that occurred during regex compilation func mustRegexFindAll(regex string, s string, n int) ([]string, error) { r, err := regexp.Compile(regex) if err != nil { @@ -26,11 +66,30 @@ func mustRegexFindAll(regex string, s string, n int) ([]string, error) { return r.FindAllString(s, n), nil } +// regexFind finds the first match of a regular expression in a string. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// +// Returns: +// - string: The first matched substring, or an empty string if no match func regexFind(regex string, s string) string { r := regexp.MustCompile(regex) return r.FindString(s) } +// mustRegexFind finds the first match of a regular expression in a string. +// Unlike regexFind, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// +// Returns: +// - string: The first matched substring, or an empty string if no match +// - error: Any error that occurred during regex compilation func mustRegexFind(regex string, s string) (string, error) { r, err := regexp.Compile(regex) if err != nil { @@ -39,11 +98,34 @@ func mustRegexFind(regex string, s string) (string, error) { return r.FindString(s), nil } +// regexReplaceAll replaces all matches of a regular expression with a replacement string. +// It panics if the regex pattern cannot be compiled. +// The replacement string can contain $1, $2, etc. for submatches. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The replacement string (can contain $1, $2, etc. for submatches) +// +// Returns: +// - string: The resulting string after all replacements func regexReplaceAll(regex string, s string, repl string) string { r := regexp.MustCompile(regex) return r.ReplaceAllString(s, repl) } +// mustRegexReplaceAll replaces all matches of a regular expression with a replacement string. +// Unlike regexReplaceAll, this function returns any errors that occur during regex compilation. +// The replacement string can contain $1, $2, etc. for submatches. +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The replacement string (can contain $1, $2, etc. for submatches) +// +// Returns: +// - string: The resulting string after all replacements +// - error: Any error that occurred during regex compilation func mustRegexReplaceAll(regex string, s string, repl string) (string, error) { r, err := regexp.Compile(regex) if err != nil { @@ -52,11 +134,34 @@ func mustRegexReplaceAll(regex string, s string, repl string) (string, error) { return r.ReplaceAllString(s, repl), nil } +// regexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string. +// It panics if the regex pattern cannot be compiled. +// Unlike regexReplaceAll, the replacement string is used literally (no $1, $2 processing). +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The literal replacement string +// +// Returns: +// - string: The resulting string after all replacements func regexReplaceAllLiteral(regex string, s string, repl string) string { r := regexp.MustCompile(regex) return r.ReplaceAllLiteralString(s, repl) } +// mustRegexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string. +// Unlike regexReplaceAllLiteral, this function returns any errors that occur during regex compilation. +// The replacement string is used literally (no $1, $2 processing). +// +// Parameters: +// - regex: The regular expression pattern to search for +// - s: The string to search within +// - repl: The literal replacement string +// +// Returns: +// - string: The resulting string after all replacements +// - error: Any error that occurred during regex compilation func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) { r, err := regexp.Compile(regex) if err != nil { @@ -65,11 +170,32 @@ func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, er return r.ReplaceAllLiteralString(s, repl), nil } +// regexSplit splits a string by a regular expression pattern. +// It panics if the regex pattern cannot be compiled. +// +// Parameters: +// - regex: The regular expression pattern to split on +// - s: The string to split +// - n: The maximum number of substrings to return (negative means all substrings) +// +// Returns: +// - []string: A slice containing the substrings between regex matches func regexSplit(regex string, s string, n int) []string { r := regexp.MustCompile(regex) return r.Split(s, n) } +// mustRegexSplit splits a string by a regular expression pattern. +// Unlike regexSplit, this function returns any errors that occur during regex compilation. +// +// Parameters: +// - regex: The regular expression pattern to split on +// - s: The string to split +// - n: The maximum number of substrings to return (negative means all substrings) +// +// Returns: +// - []string: A slice containing the substrings between regex matches +// - error: Any error that occurred during regex compilation func mustRegexSplit(regex string, s string, n int) ([]string, error) { r, err := regexp.Compile(regex) if err != nil { @@ -78,6 +204,14 @@ func mustRegexSplit(regex string, s string, n int) ([]string, error) { return r.Split(s, n), nil } +// regexQuoteMeta escapes all regular expression metacharacters in a string. +// This is useful when you want to use a string as a literal in a regular expression. +// +// Parameters: +// - s: The string to escape +// +// Returns: +// - string: The escaped string with all regex metacharacters quoted func regexQuoteMeta(s string) string { return regexp.QuoteMeta(s) } diff --git a/util/sprig/strings.go b/util/sprig/strings.go index 8a1bdc1b..e64f82d9 100644 --- a/util/sprig/strings.go +++ b/util/sprig/strings.go @@ -11,10 +11,25 @@ import ( "strings" ) +// base64encode encodes a string to base64 using standard encoding. +// +// Parameters: +// - v: The string to encode +// +// Returns: +// - string: The base64 encoded string func base64encode(v string) string { return base64.StdEncoding.EncodeToString([]byte(v)) } +// base64decode decodes a base64 encoded string. +// If the input is not valid base64, it returns the error message as a string. +// +// Parameters: +// - v: The base64 encoded string to decode +// +// Returns: +// - string: The decoded string, or an error message if decoding fails func base64decode(v string) string { data, err := base64.StdEncoding.DecodeString(v) if err != nil { @@ -23,10 +38,25 @@ func base64decode(v string) string { return string(data) } +// base32encode encodes a string to base32 using standard encoding. +// +// Parameters: +// - v: The string to encode +// +// Returns: +// - string: The base32 encoded string func base32encode(v string) string { return base32.StdEncoding.EncodeToString([]byte(v)) } +// base32decode decodes a base32 encoded string. +// If the input is not valid base32, it returns the error message as a string. +// +// Parameters: +// - v: The base32 encoded string to decode +// +// Returns: +// - string: The decoded string, or an error message if decoding fails func base32decode(v string) string { data, err := base32.StdEncoding.DecodeString(v) if err != nil { @@ -35,6 +65,14 @@ func base32decode(v string) string { return string(data) } +// quote adds double quotes around each non-nil string in the input and joins them with spaces. +// This uses Go's %q formatter which handles escaping special characters. +// +// Parameters: +// - str: A variadic list of values to quote +// +// Returns: +// - string: The quoted strings joined with spaces func quote(str ...any) string { out := make([]string, 0, len(str)) for _, s := range str { @@ -45,6 +83,14 @@ func quote(str ...any) string { return strings.Join(out, " ") } +// squote adds single quotes around each non-nil value in the input and joins them with spaces. +// Unlike quote, this doesn't escape special characters. +// +// Parameters: +// - str: A variadic list of values to quote +// +// Returns: +// - string: The single-quoted values joined with spaces func squote(str ...any) string { out := make([]string, 0, len(str)) for _, s := range str { @@ -55,25 +101,69 @@ func squote(str ...any) string { return strings.Join(out, " ") } +// cat concatenates all non-nil values into a single string. +// Nil values are removed before concatenation. +// +// Parameters: +// - v: A variadic list of values to concatenate +// +// Returns: +// - string: The concatenated string func cat(v ...any) string { v = removeNilElements(v) r := strings.TrimSpace(strings.Repeat("%v ", len(v))) return fmt.Sprintf(r, v...) } +// indent adds a specified number of spaces at the beginning of each line in a string. +// +// Parameters: +// - spaces: The number of spaces to add +// - v: The string to indent +// +// Returns: +// - string: The indented string func indent(spaces int, v string) string { pad := strings.Repeat(" ", spaces) return pad + strings.Replace(v, "\n", "\n"+pad, -1) } +// nindent adds a newline followed by an indented string. +// It's a shorthand for "\n" + indent(spaces, v). +// +// Parameters: +// - spaces: The number of spaces to add +// - v: The string to indent +// +// Returns: +// - string: A newline followed by the indented string func nindent(spaces int, v string) string { return "\n" + indent(spaces, v) } +// replace replaces all occurrences of a substring with another substring. +// +// Parameters: +// - old: The substring to replace +// - new: The replacement substring +// - src: The source string +// +// Returns: +// - string: The resulting string after all replacements func replace(old, new, src string) string { return strings.Replace(src, old, new, -1) } +// plural returns the singular or plural form of a word based on the count. +// If count is 1, it returns the singular form, otherwise it returns the plural form. +// +// Parameters: +// - one: The singular form of the word +// - many: The plural form of the word +// - count: The count to determine which form to use +// +// Returns: +// - string: Either the singular or plural form based on the count func plural(one, many string, count int) string { if count == 1 { return one @@ -81,6 +171,19 @@ func plural(one, many string, count int) string { return many } +// strslice converts a value to a slice of strings. +// It handles various input types: +// - []string: returned as is +// - []any: converted to []string, skipping nil values +// - arrays and slices: converted to []string, skipping nil values +// - nil: returns an empty slice +// - anything else: returns a single-element slice with the string representation +// +// Parameters: +// - v: The value to convert to a string slice +// +// Returns: +// - []string: A slice of strings func strslice(v any) []string { switch v := v.(type) { case []string: @@ -116,6 +219,14 @@ func strslice(v any) []string { } } +// removeNilElements creates a new slice with all nil elements removed. +// This is a helper function used by other functions like cat. +// +// Parameters: +// - v: The slice to process +// +// Returns: +// - []any: A new slice with all nil elements removed func removeNilElements(v []any) []any { newSlice := make([]any, 0, len(v)) for _, i := range v { @@ -126,6 +237,19 @@ func removeNilElements(v []any) []any { return newSlice } +// strval converts any value to a string. +// It handles various types: +// - string: returned as is +// - []byte: converted to string +// - error: returns the error message +// - fmt.Stringer: calls the String() method +// - anything else: uses fmt.Sprintf("%v", v) +// +// Parameters: +// - v: The value to convert to a string +// +// Returns: +// - string: The string representation of the value func strval(v any) string { switch v := v.(type) { case string: @@ -141,6 +265,17 @@ func strval(v any) string { } } +// trunc truncates a string to a specified length. +// If c is positive, it returns the first c characters. +// If c is negative, it returns the last |c| characters. +// If the string is shorter than the requested length, it returns the original string. +// +// Parameters: +// - c: The number of characters to keep (positive from start, negative from end) +// - s: The string to truncate +// +// Returns: +// - string: The truncated string func trunc(c int, s string) string { if c < 0 && len(s)+c > 0 { return s[len(s)+c:] @@ -151,14 +286,40 @@ func trunc(c int, s string) string { return s } +// title converts a string to title case. +// This uses the English language rules for capitalization. +// +// Parameters: +// - s: The string to convert +// +// Returns: +// - string: The string in title case func title(s string) string { return cases.Title(language.English).String(s) } +// join concatenates the elements of a slice with a separator. +// The input is first converted to a string slice using strslice. +// +// Parameters: +// - sep: The separator to use between elements +// - v: The value to join (will be converted to a string slice) +// +// Returns: +// - string: The joined string func join(sep string, v any) string { return strings.Join(strslice(v), sep) } +// split splits a string by a separator and returns a map. +// The keys in the map are "_0", "_1", etc., corresponding to the position of each part. +// +// Parameters: +// - sep: The separator to split on +// - orig: The string to split +// +// Returns: +// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts func split(sep, orig string) map[string]string { parts := strings.Split(orig, sep) res := make(map[string]string, len(parts)) @@ -168,10 +329,30 @@ func split(sep, orig string) map[string]string { return res } +// splitList splits a string by a separator and returns a slice. +// This is a simple wrapper around strings.Split. +// +// Parameters: +// - sep: The separator to split on +// - orig: The string to split +// +// Returns: +// - []string: A slice containing the split parts func splitList(sep, orig string) []string { return strings.Split(orig, sep) } +// splitn splits a string by a separator with a limit and returns a map. +// The keys in the map are "_0", "_1", etc., corresponding to the position of each part. +// It will split the string into at most n parts. +// +// Parameters: +// - sep: The separator to split on +// - n: The maximum number of parts to return +// - orig: The string to split +// +// Returns: +// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts func splitn(sep string, n int, orig string) map[string]string { parts := strings.SplitN(orig, sep, n) res := make(map[string]string, len(parts)) @@ -182,12 +363,20 @@ func splitn(sep string, n int, orig string) map[string]string { } // substring creates a substring of the given string. +// It extracts a portion of a string based on start and end indices. // -// If start is < 0, this calls string[:end]. +// Parameters: +// - start: The starting index (inclusive) +// - end: The ending index (exclusive) +// - s: The source string // -// If start is >= 0 and end < 0 or end bigger than s length, this calls string[start:] +// Behavior: +// - If start < 0, returns s[:end] +// - If start >= 0 and end < 0 or end > len(s), returns s[start:] +// - Otherwise, returns s[start:end] // -// Otherwise, this calls string[start, end]. +// Returns: +// - string: The extracted substring func substring(start, end int, s string) string { if start < 0 { return s[:end] @@ -198,6 +387,19 @@ func substring(start, end int, s string) string { return s[start:end] } +// repeat creates a new string by repeating the input string a specified number of times. +// It has safety limits to prevent excessive memory usage or infinite loops. +// +// Parameters: +// - count: The number of times to repeat the string +// - str: The string to repeat +// +// Returns: +// - string: The repeated string +// +// Panics: +// - If count exceeds loopExecutionLimit +// - If the resulting string length would exceed stringLengthLimit func repeat(count int, str string) string { if count > loopExecutionLimit { panic(fmt.Sprintf("repeat count %d exceeds limit of %d", count, loopExecutionLimit)) @@ -207,26 +409,79 @@ func repeat(count int, str string) string { return strings.Repeat(str, count) } +// trimAll removes all leading and trailing characters contained in the cutset. +// Note that the parameter order is reversed from the standard strings.Trim function. +// +// Parameters: +// - a: The cutset of characters to remove +// - b: The string to trim +// +// Returns: +// - string: The trimmed string func trimAll(a, b string) string { return strings.Trim(b, a) } +// trimPrefix removes the specified prefix from a string. +// If the string doesn't start with the prefix, it returns the original string. +// Note that the parameter order is reversed from the standard strings.TrimPrefix function. +// +// Parameters: +// - a: The prefix to remove +// - b: The string to trim +// +// Returns: +// - string: The string with the prefix removed, or the original string if it doesn't start with the prefix func trimPrefix(a, b string) string { return strings.TrimPrefix(b, a) } +// trimSuffix removes the specified suffix from a string. +// If the string doesn't end with the suffix, it returns the original string. +// Note that the parameter order is reversed from the standard strings.TrimSuffix function. +// +// Parameters: +// - a: The suffix to remove +// - b: The string to trim +// +// Returns: +// - string: The string with the suffix removed, or the original string if it doesn't end with the suffix func trimSuffix(a, b string) string { return strings.TrimSuffix(b, a) } +// contains checks if a string contains a substring. +// +// Parameters: +// - substr: The substring to search for +// - str: The string to search in +// +// Returns: +// - bool: True if str contains substr, false otherwise func contains(substr string, str string) bool { return strings.Contains(str, substr) } +// hasPrefix checks if a string starts with a specified prefix. +// +// Parameters: +// - substr: The prefix to check for +// - str: The string to check +// +// Returns: +// - bool: True if str starts with substr, false otherwise func hasPrefix(substr string, str string) bool { return strings.HasPrefix(str, substr) } +// hasSuffix checks if a string ends with a specified suffix. +// +// Parameters: +// - substr: The suffix to check for +// - str: The string to check +// +// Returns: +// - bool: True if str ends with substr, false otherwise func hasSuffix(substr string, str string) bool { return strings.HasSuffix(str, substr) } diff --git a/util/sprig/url.go b/util/sprig/url.go index 00826706..52dac3bb 100644 --- a/util/sprig/url.go +++ b/util/sprig/url.go @@ -60,7 +60,6 @@ func urlJoin(d map[string]any) string { } user = tempURL.User } - resURL.User = user return resURL.String() } From d87d8a2db4f15ff8e03f8b964daa2ddb8fd4c4da Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 11:31:38 +0200 Subject: [PATCH 56/87] fmt --- util/sprig/dict.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/util/sprig/dict.go b/util/sprig/dict.go index 0a282add..4bb16d03 100644 --- a/util/sprig/dict.go +++ b/util/sprig/dict.go @@ -185,9 +185,9 @@ func values(dict map[string]any) []any { // // Parameters: // - ps: A variadic list where: -// - The first N-2 arguments are string keys forming the path -// - The second-to-last argument is the default value to return if the path doesn't exist -// - The last argument is the map to traverse +// - The first N-2 arguments are string keys forming the path +// - The second-to-last argument is the default value to return if the path doesn't exist +// - The last argument is the map to traverse // // Returns: // - any: The value found at the specified path, or the default value if not found From f298d947bd12c35cbe9eab5e81f044e465e34b93 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 11:46:22 +0200 Subject: [PATCH 57/87] Bump --- go.mod | 30 +-- go.sum | 31 +++ server/smtp_server.go | 6 +- web/package-lock.json | 475 ++++++++++++++++++++++-------------------- 4 files changed, 296 insertions(+), 246 deletions(-) diff --git a/go.mod b/go.mod index dc35ae8b..4ecc680a 100644 --- a/go.mod +++ b/go.mod @@ -16,12 +16,12 @@ require ( github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.40.0 golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 - golang.org/x/term v0.32.0 + golang.org/x/sync v0.16.0 + golang.org/x/term v0.33.0 golang.org/x/time v0.12.0 - google.golang.org/api v0.240.0 + google.golang.org/api v0.242.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -30,18 +30,18 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi require github.com/pkg/errors v0.9.1 // indirect require ( - firebase.google.com/go/v4 v4.16.1 + firebase.google.com/go/v4 v4.17.0 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.22.0 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/text v0.26.0 + golang.org/x/text v0.27.0 ) require ( cel.dev/expr v0.24.0 // indirect - cloud.google.com/go v0.121.3 // indirect - cloud.google.com/go/auth v0.16.2 // indirect + cloud.google.com/go v0.121.4 // indirect + cloud.google.com/go/auth v0.16.3 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.7.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect @@ -65,12 +65,12 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect @@ -92,12 +92,12 @@ require ( go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 18815b70..1f98da35 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,12 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= +cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= +cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= +cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= +cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= @@ -24,6 +28,8 @@ cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4 cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4= firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= +firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE= +firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= @@ -83,6 +89,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -100,6 +108,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= +github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -186,6 +196,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -202,6 +214,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -213,6 +227,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -227,6 +243,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -238,6 +256,8 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -251,6 +271,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -263,14 +285,23 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= +google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= +google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= +google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/server/smtp_server.go b/server/smtp_server.go index 6de42e37..ee28efc2 100644 --- a/server/smtp_server.go +++ b/server/smtp_server.go @@ -192,12 +192,12 @@ func (s *smtpSession) publishMessage(m *message) error { // Call HTTP handler with fake HTTP request url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic) req, err := http.NewRequest("POST", url, strings.NewReader(m.Message)) - req.RequestURI = "/" + m.Topic // just for the logs - req.RemoteAddr = remoteAddr // rate limiting!! - req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header if err != nil { return err } + req.RequestURI = "/" + m.Topic // just for the logs + req.RemoteAddr = remoteAddr // rate limiting!! + req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header if m.Title != "" { req.Header.Set("Title", m.Title) } diff --git a/web/package-lock.json b/web/package-lock.json index ea4962a4..28e5b6d3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1261,9 +1261,9 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", - "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", + "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -1599,9 +1599,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1770,9 +1770,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -1787,9 +1787,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -1804,9 +1804,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -1821,9 +1821,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -1838,9 +1838,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -1855,9 +1855,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -1872,9 +1872,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -1889,9 +1889,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -1906,9 +1906,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -1923,9 +1923,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -1940,9 +1940,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -1957,9 +1957,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -1974,9 +1974,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -1991,9 +1991,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -2008,9 +2008,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -2025,9 +2025,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -2042,9 +2042,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -2059,9 +2059,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -2076,9 +2076,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -2093,9 +2093,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -2110,9 +2110,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -2126,10 +2126,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -2144,9 +2161,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -2161,9 +2178,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -2178,9 +2195,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -2354,9 +2371,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.17.1.tgz", - "integrity": "sha512-OcZj+cs6EfUD39IoPBOgN61zf1XFVY+imsGoBDwXeSq2UHJZE3N59zzBOVjclck91Ne3e9gudONOeILvHCIhUA==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", + "integrity": "sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==", "license": "MIT", "funding": { "type": "opencollective", @@ -2364,9 +2381,9 @@ } }, "node_modules/@mui/icons-material": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.17.1.tgz", - "integrity": "sha512-CN86LocjkunFGG0yPlO4bgqHkNGgaEOEc3X/jG5Bzm401qYw79/SaLrofA7yAKCCXAGdIGnLoMHohc3+ubs95A==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.18.0.tgz", + "integrity": "sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9" @@ -2390,14 +2407,14 @@ } }, "node_modules/@mui/material": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.17.1.tgz", - "integrity": "sha512-2B33kQf+GmPnrvXXweWAx+crbiUEsxCdCN979QDYnlH9ox4pd+0/IBriWLV+l6ORoBF60w39cWjFnJYGFdzXcw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.18.0.tgz", + "integrity": "sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/core-downloads-tracker": "^5.17.1", - "@mui/system": "^5.17.1", + "@mui/core-downloads-tracker": "^5.18.0", + "@mui/system": "^5.18.0", "@mui/types": "~7.2.15", "@mui/utils": "^5.17.1", "@popperjs/core": "^2.11.8", @@ -2462,13 +2479,14 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.16.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.16.14.tgz", - "integrity": "sha512-UAiMPZABZ7p8mUW4akDV6O7N3+4DatStpXMZwPlt+H/dA0lt67qawN021MNND+4QTpjaiMYxbhKZeQcyWCbuKw==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.18.0.tgz", + "integrity": "sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -2494,14 +2512,14 @@ } }, "node_modules/@mui/system": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.17.1.tgz", - "integrity": "sha512-aJrmGfQpyF0U4D4xYwA6ueVtQcEMebET43CUmKMP7e7iFh3sMIF3sBR0l8Urb4pqx1CBjHAaWgB0ojpND4Q3Jg==", + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.18.0.tgz", + "integrity": "sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.9", "@mui/private-theming": "^5.17.1", - "@mui/styled-engine": "^5.16.14", + "@mui/styled-engine": "^5.18.0", "@mui/types": "~7.2.15", "@mui/utils": "^5.17.1", "clsx": "^2.1.0", @@ -2635,9 +2653,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, @@ -2713,9 +2731,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "cpu": [ "arm" ], @@ -2727,9 +2745,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "cpu": [ "arm64" ], @@ -2741,9 +2759,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "cpu": [ "arm64" ], @@ -2755,9 +2773,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "cpu": [ "x64" ], @@ -2769,9 +2787,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "cpu": [ "arm64" ], @@ -2783,9 +2801,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "cpu": [ "x64" ], @@ -2797,9 +2815,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "cpu": [ "arm" ], @@ -2811,9 +2829,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "cpu": [ "arm" ], @@ -2825,9 +2843,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "cpu": [ "arm64" ], @@ -2839,9 +2857,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "cpu": [ "arm64" ], @@ -2853,9 +2871,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "cpu": [ "loong64" ], @@ -2867,9 +2885,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "cpu": [ "ppc64" ], @@ -2881,9 +2899,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "cpu": [ "riscv64" ], @@ -2895,9 +2913,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "cpu": [ "riscv64" ], @@ -2909,9 +2927,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "cpu": [ "s390x" ], @@ -2923,9 +2941,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "cpu": [ "x64" ], @@ -2937,9 +2955,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "cpu": [ "x64" ], @@ -2951,9 +2969,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "cpu": [ "arm64" ], @@ -2965,9 +2983,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "cpu": [ "ia32" ], @@ -2979,9 +2997,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "cpu": [ "x64" ], @@ -3139,16 +3157,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -3156,7 +3174,7 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/acorn": { @@ -4094,9 +4112,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.179", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "version": "1.5.187", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.187.tgz", + "integrity": "sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==", "dev": true, "license": "ISC" }, @@ -4303,9 +4321,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4316,31 +4334,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -4465,9 +4484,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", - "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", + "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", "dev": true, "license": "MIT", "bin": { @@ -6839,9 +6858,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -7364,9 +7383,9 @@ } }, "node_modules/rollup": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "license": "MIT", "dependencies": { @@ -7380,26 +7399,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.2", - "@rollup/rollup-android-arm64": "4.44.2", - "@rollup/rollup-darwin-arm64": "4.44.2", - "@rollup/rollup-darwin-x64": "4.44.2", - "@rollup/rollup-freebsd-arm64": "4.44.2", - "@rollup/rollup-freebsd-x64": "4.44.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", - "@rollup/rollup-linux-arm-musleabihf": "4.44.2", - "@rollup/rollup-linux-arm64-gnu": "4.44.2", - "@rollup/rollup-linux-arm64-musl": "4.44.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-musl": "4.44.2", - "@rollup/rollup-linux-s390x-gnu": "4.44.2", - "@rollup/rollup-linux-x64-gnu": "4.44.2", - "@rollup/rollup-linux-x64-musl": "4.44.2", - "@rollup/rollup-win32-arm64-msvc": "4.44.2", - "@rollup/rollup-win32-ia32-msvc": "4.44.2", - "@rollup/rollup-win32-x64-msvc": "4.44.2", + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" } }, From f59df0f40ada3221a1857c665c9c82e0fdf63d2e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 21 Jul 2025 17:44:00 +0200 Subject: [PATCH 58/87] Works --- Makefile | 2 +- cmd/access.go | 30 ++++-- cmd/serve.go | 109 ++++++++++++++----- cmd/user.go | 3 +- go.sum | 31 ------ server/message_cache.go | 7 ++ server/server.go | 5 +- server/server.yml | 6 ++ server/server_admin.go | 2 +- user/manager.go | 230 +++++++++++++++++++++++++++++++--------- user/manager_test.go | 10 +- user/types.go | 26 ++--- util/util.go | 12 +++ 13 files changed, 333 insertions(+), 140 deletions(-) diff --git a/Makefile b/Makefile index 575bb788..df131c7a 100644 --- a/Makefile +++ b/Makefile @@ -232,7 +232,7 @@ cli-deps-update: go get -u go install honnef.co/go/tools/cmd/staticcheck@latest go install golang.org/x/lint/golint@latest - go install github.com/goreleaser/goreleaser@latest + go install github.com/goreleaser/goreleaser/v2@latest cli-build-results: cat dist/config.yaml diff --git a/cmd/access.go b/cmd/access.go index c6be94b5..10247b5f 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err } else if u.Role == user.RoleAdmin { return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) } @@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error { func showUserAccess(c *cli.Context, manager *user.Manager, username string) error { users, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -193,19 +195,27 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error if u.Tier != nil { tier = u.Tier.Name } - fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier) + provisioned := "" + if u.Provisioned { + provisioned = ", provisioned user" + } + fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned) if u.Role == user.RoleAdmin { fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") } else if len(grants) > 0 { for _, grant := range grants { - if grant.Allow.IsReadWrite() { - fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) - } else if grant.Allow.IsRead() { - fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern) - } else if grant.Allow.IsWrite() { - fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern) + grantProvisioned := "" + if grant.Provisioned { + grantProvisioned = ", provisioned access entry" + } + if grant.Permission.IsReadWrite() { + fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsRead() { + fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned) + } else if grant.Permission.IsWrite() { + fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } else { - fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern) + fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned) } } } else { diff --git a/cmd/serve.go b/cmd/serve.go index 50314b88..ef37ee6f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,7 +48,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provisioned-users", Aliases: []string{"auth_provisioned_users"}, EnvVars: []string{"NTFY_AUTH_PROVISIONED_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -155,8 +156,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authProvisionedUsersRaw := c.StringSlice("auth-provisioned-users") - //authProvisionedAccessRaw := c.StringSlice("auth-provisioned-access") + authProvisionUsersRaw := c.StringSlice("auth-provision-users") + authProvisionAccessRaw := c.StringSlice("auth-provision-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -352,27 +353,13 @@ func execServe(c *cli.Context) error { if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - authProvisionedUsers := make([]*user.User, 0) - for _, userLine := range authProvisionedUsersRaw { - parts := strings.Split(userLine, ":") - if len(parts) != 3 { - return fmt.Errorf("invalid provisioned user %s, expected format: 'name:hash:role'", userLine) - } - username := strings.TrimSpace(parts[0]) - passwordHash := strings.TrimSpace(parts[1]) - role := user.Role(strings.TrimSpace(parts[2])) - if !user.AllowedUsername(username) { - return fmt.Errorf("invalid provisioned user %s, username invalid", userLine) - } else if passwordHash == "" { - return fmt.Errorf("invalid provisioned user %s, password hash cannot be empty", userLine) - } else if !user.AllowedRole(role) { - return fmt.Errorf("invalid provisioned user %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) - } - authProvisionedUsers = append(authProvisionedUsers, &user.User{ - Name: username, - Hash: passwordHash, - Role: role, - }) + authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw) + if err != nil { + return err + } + authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw) + if err != nil { + return err } // Special case: Unset default @@ -429,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionedUsers = authProvisionedUsers - conf.AuthProvisionedAccess = nil // FIXME + conf.AuthProvisionedUsers = authProvisionUsers + conf.AuthProvisionedAccess = authProvisionAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -544,6 +531,76 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } +func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { + provisionUsers := make([]*user.User, 0) + for _, userLine := range usersRaw { + parts := strings.Split(userLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine) + } + username := strings.TrimSpace(parts[0]) + passwordHash := strings.TrimSpace(parts[1]) + role := user.Role(strings.TrimSpace(parts[2])) + if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine) + } else if passwordHash == "" { + return nil, fmt.Errorf("invalid auth-provision-users: %s, password hash cannot be empty", userLine) + } else if !user.AllowedRole(role) { + return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + } + provisionUsers = append(provisionUsers, &user.User{ + Name: username, + Hash: passwordHash, + Role: role, + Provisioned: true, + }) + } + return provisionUsers, nil +} + +func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { + access := make(map[string][]*user.Grant) + for _, accessLine := range provisionAccessRaw { + parts := strings.Split(accessLine, ":") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine) + } + username := strings.TrimSpace(parts[0]) + if username == userEveryone { + username = user.Everyone + } + provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool { + return u.Name == username + }) + if username != user.Everyone { + if !exists { + return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username) + } else if provisionUser.Role != user.RoleUser { + return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) + } + } + topic := strings.TrimSpace(parts[1]) + if !user.AllowedTopicPattern(topic) { + return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic) + } + permission, err := user.ParsePermission(strings.TrimSpace(parts[2])) + if err != nil { + return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) + } + if _, exists := access[username]; !exists { + access[username] = make([]*user.Grant, 0) + } + access[username] = append(access[username], &user.Grant{ + TopicPattern: topic, + Permission: permission, + Provisioned: true, + }) + } + return access, nil +} + func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { diff --git a/cmd/user.go b/cmd/user.go index 31f4c31b..0a6e24a1 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -349,8 +349,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) { Filename: authFile, StartupQueries: authStartupQueries, DefaultAccess: authDefault, - ProvisionedUsers: nil, //FIXME - ProvisionedAccess: nil, //FIXME + ProvisionEnabled: false, // Do not re-provision users on manager initialization BcryptCost: user.DefaultUserPasswordBcryptCost, QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval, } diff --git a/go.sum b/go.sum index 1f98da35..575b5c22 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,7 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo= -cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc= cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs= cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s= -cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4= -cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA= cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -26,8 +22,6 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4= -firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= firebase.google.com/go/v4 v4.17.0 h1:Bih69QV/k0YKPA1qUX04ln0aPT9IERrAo2ezibcngzE= firebase.google.com/go/v4 v4.17.0/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -87,8 +81,6 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -106,8 +98,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= -github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -194,8 +184,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -212,8 +200,6 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -225,8 +211,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -241,8 +225,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -254,8 +236,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -269,8 +249,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= @@ -283,23 +261,14 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU= -google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg= google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJdz6KhTIs2VRx/iOsA5iE8bmQNcxs= google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79 h1:iOye66xuaAK0WnkPuhQPUFy8eJcmwUXqGGP3om6IxX8= google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 h1:1ZwqphdOdWYXsUHgMpU/101nCtf/kSp9hOrcvFsnl10= google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= diff --git a/server/message_cache.go b/server/message_cache.go index e314ace3..03cb4969 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/netip" + "path/filepath" "strings" "time" @@ -286,6 +287,12 @@ type messageCache struct { // newSqliteCache creates a SQLite file-backed cache func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) { + // Check the parent directory of the database file (makes for friendly error messages) + parentDir := filepath.Dir(filename) + if !util.FileExists(parentDir) { + return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir) + } + // Open database db, err := sql.Open("sqlite3", filename) if err != nil { return nil, err diff --git a/server/server.go b/server/server.go index d585faa0..d3ef9cbb 100644 --- a/server/server.go +++ b/server/server.go @@ -200,8 +200,9 @@ func New(conf *Config) (*Server, error) { Filename: conf.AuthFile, StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, - ProvisionedUsers: conf.AuthProvisionedUsers, - ProvisionedAccess: conf.AuthProvisionedAccess, + ProvisionEnabled: true, // Enable provisioning of users and access + ProvisionUsers: conf.AuthProvisionedUsers, + ProvisionAccess: conf.AuthProvisionedAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/server/server.yml b/server/server.yml index db968498..02af7383 100644 --- a/server/server.yml +++ b/server/server.yml @@ -82,6 +82,10 @@ # set to "read-write" (default), "read-only", "write-only" or "deny-all". # - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable # WAL mode. This is similar to cache-startup-queries. See above for details. +# - auth-provision-users is a list of users that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" +# - auth-provision-access is a list of access control entries that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". # # Debian/RPM package users: # Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package @@ -94,6 +98,8 @@ # auth-file: # auth-default-access: "read-write" # auth-startup-queries: +# auth-provision-users: +# auth-provision-access: # If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine # the visitor IP address instead of the remote address of the connection. diff --git a/server/server_admin.go b/server/server_admin.go index eb362956..b724d4b7 100644 --- a/server/server_admin.go +++ b/server/server_admin.go @@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit for i, g := range grants[u.ID] { userGrants[i] = &apiUserGrantResponse{ Topic: g.TopicPattern, - Permission: g.Allow.String(), + Permission: g.Permission.String(), } } usersResponse[i] = &apiUserResponse{ diff --git a/user/manager.go b/user/manager.go index 8932f34a..f2f4875d 100644 --- a/user/manager.go +++ b/user/manager.go @@ -12,6 +12,7 @@ import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" "net/netip" + "path/filepath" "strings" "sync" "time" @@ -75,6 +76,7 @@ const ( role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, prefs JSON NOT NULL DEFAULT '{}', sync_topic TEXT NOT NULL, + provisioned INT NOT NULL, stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0), stats_calls INT NOT NULL DEFAULT (0), @@ -97,6 +99,7 @@ const ( read INT NOT NULL, write INT NOT NULL, owner_user_id INT, + provisioned INT NOT NULL, PRIMARY KEY (user_id, topic), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE @@ -121,8 +124,8 @@ const ( id INT PRIMARY KEY, version INT NOT NULL ); - INSERT INTO user (id, user, pass, role, sync_topic, created) - VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH()) + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH()) ON CONFLICT (id) DO NOTHING; COMMIT; ` @@ -132,26 +135,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -165,8 +168,8 @@ const ( ` insertUserQuery = ` - INSERT INTO user (id, user, pass, role, sync_topic, created) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES (?, ?, ?, ?, ?, ?, ?) ` selectUsernamesQuery = ` SELECT user @@ -189,18 +192,18 @@ const ( deleteUserQuery = `DELETE FROM user WHERE user = ?` upsertUserAccessQuery = ` - INSERT INTO user_access (user_id, topic, read, write, owner_user_id) - VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) + INSERT INTO user_access (user_id, topic, read, write, owner_user_id, provisioned) + VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))), ?) ON CONFLICT (user_id, topic) - DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id, provisioned=excluded.provisioned ` selectUserAllAccessQuery = ` - SELECT user_id, topic, read, write + SELECT user_id, topic, read, write, provisioned FROM user_access ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic ` selectUserAccessQuery = ` - SELECT topic, read, write + SELECT topic, read, write, provisioned FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) ORDER BY LENGTH(topic) DESC, write DESC, read DESC, topic @@ -244,7 +247,8 @@ const ( WHERE user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?) ` - deleteTopicAccessQuery = ` + deleteUserAccessProvisionedQuery = `DELETE FROM user_access WHERE provisioned = 1` + deleteTopicAccessQuery = ` DELETE FROM user_access WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?)) AND topic = ? @@ -427,6 +431,15 @@ const ( migrate4To5UpdateQueries = ` UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); ` + + // 5 -> 6 + migrate5To6UpdateQueries = ` + ALTER TABLE user ADD COLUMN provisioned INT NOT NULL DEFAULT (0); + ALTER TABLE user ALTER COLUMN provisioned DROP DEFAULT; + + ALTER TABLE user_access ADD COLUMN provisioned INT NOT NULL DEFAULT (0); + ALTER TABLE user_access ALTER COLUMN provisioned DROP DEFAULT; + ` ) var ( @@ -435,6 +448,7 @@ var ( 2: migrateFrom2, 3: migrateFrom3, 4: migrateFrom4, + 5: migrateFrom5, } ) @@ -452,8 +466,9 @@ type Config struct { Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches - ProvisionedUsers []*User // Predefined users to create on startup - ProvisionedAccess map[string][]*Grant // Predefined access grants to create on startup + ProvisionEnabled bool // Enable auto-provisioning of users and access grants + ProvisionUsers []*User // Predefined users to create on startup + ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database BcryptCost int // Cost of generated passwords; lowering makes testing faster } @@ -469,6 +484,11 @@ func NewManager(config *Config) (*Manager, error) { if config.QueueWriterInterval.Seconds() <= 0 { config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval } + // Check the parent directory of the database file (makes for friendly error messages) + parentDir := filepath.Dir(config.Filename) + if !util.FileExists(parentDir) { + return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir) + } // Open DB and run setup queries db, err := sql.Open("sqlite3", config.Filename) if err != nil { @@ -486,7 +506,7 @@ func NewManager(config *Config) (*Manager, error) { statsQueue: make(map[string]*Stats), tokenQueue: make(map[string]*TokenUpdate), } - if err := manager.provisionUsers(); err != nil { + if err := manager.maybeProvisionUsersAndAccess(); err != nil { return nil, err } go manager.asyncQueueWriter(config.QueueWriterInterval) @@ -586,7 +606,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) { tokens := make([]*Token, 0) for { token, err := a.readToken(rows) - if err == ErrTokenNotFound { + if errors.Is(err, ErrTokenNotFound) { break } else if err != nil { return nil, err @@ -884,6 +904,13 @@ func (a *Manager) resolvePerms(base, perm Permission) error { // AddUser adds a user with the given username, password and role func (a *Manager) AddUser(username, password string, role Role, hashed bool) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.addUserTx(tx, username, password, role, hashed, false) + }) +} + +// AddUser adds a user with the given username, password and role +func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, hashed, provisioned bool) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } @@ -899,8 +926,8 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err } userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() - if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { - if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + if _, err = tx.Exec(insertUserQuery, userID, username, hash, role, syncTopic, provisioned, now); err != nil { + if errors.Is(err, sqlite3.ErrConstraintUnique) { return ErrUserExists } return err @@ -911,11 +938,17 @@ func (a *Manager) AddUser(username, password string, role Role, hashed bool) err // RemoveUser deletes the user with the given username. The function returns nil on success, even // if the user did not exist in the first place. func (a *Manager) RemoveUser(username string) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.removeUserTx(tx, username) + }) +} + +func (a *Manager) removeUserTx(tx *sql.Tx, username string) error { if !AllowedUsername(username) { return ErrInvalidArgument } // Rows in user_access, user_token, etc. are deleted via foreign keys - if _, err := a.db.Exec(deleteUserQuery, username); err != nil { + if _, err := tx.Exec(deleteUserQuery, username); err != nil { return err } return nil @@ -1029,24 +1062,26 @@ func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string + var provisioned bool var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var messages, emails, calls int64 var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &provisioned, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err } user := &User{ - ID: id, - Name: username, - Hash: hash, - Role: Role(role), - Prefs: &Prefs{}, - SyncTopic: syncTopic, + ID: id, + Name: username, + Hash: hash, + Role: Role(role), + Prefs: &Prefs{}, + SyncTopic: syncTopic, + Provisioned: provisioned, Stats: &Stats{ Messages: messages, Emails: emails, @@ -1097,8 +1132,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) { grants := make(map[string][]Grant, 0) for rows.Next() { var userID, topic string - var read, write bool - if err := rows.Scan(&userID, &topic, &read, &write); err != nil { + var read, write, provisioned bool + if err := rows.Scan(&userID, &topic, &read, &write, &provisioned); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1108,7 +1143,8 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) { } grants[userID] = append(grants[userID], Grant{ TopicPattern: fromSQLWildcard(topic), - Allow: NewPermission(read, write), + Permission: NewPermission(read, write), + Provisioned: provisioned, }) } return grants, nil @@ -1124,15 +1160,16 @@ func (a *Manager) Grants(username string) ([]Grant, error) { grants := make([]Grant, 0) for rows.Next() { var topic string - var read, write bool - if err := rows.Scan(&topic, &read, &write); err != nil { + var read, write, provisioned bool + if err := rows.Scan(&topic, &read, &write, &provisioned); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err } grants = append(grants, Grant{ TopicPattern: fromSQLWildcard(topic), - Allow: NewPermission(read, write), + Permission: NewPermission(read, write), + Provisioned: provisioned, }) } return grants, nil @@ -1218,9 +1255,14 @@ func (a *Manager) ReservationOwner(topic string) (string, error) { // ChangePassword changes a user's password func (a *Manager) ChangePassword(username, password string, hashed bool) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changePasswordTx(tx, username, password, hashed) + }) +} + +func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { var hash []byte var err error - if hashed { hash = []byte(password) } else { @@ -1229,7 +1271,7 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { return err } } - if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil { + if _, err := tx.Exec(updateUserPassQuery, hash, username); err != nil { return err } return nil @@ -1238,14 +1280,20 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin, // all existing access control entries (Grant) are removed, since they are no longer needed. func (a *Manager) ChangeRole(username string, role Role) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeRoleTx(tx, username, role) + }) +} + +func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } - if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil { + if _, err := tx.Exec(updateUserRoleQuery, string(role), username); err != nil { return err } if role == RoleAdmin { - if _, err := a.db.Exec(deleteUserAccessQuery, username, username); err != nil { + if _, err := tx.Exec(deleteUserAccessQuery, username, username); err != nil { return err } } @@ -1325,13 +1373,19 @@ func (a *Manager) AllowReservation(username string, topic string) error { // read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry // owner may either be a user (username), or the system (empty). func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.allowAccessTx(tx, username, topicPattern, permission, false) + }) +} + +func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error { if !AllowedUsername(username) && username != Everyone { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) { return ErrInvalidArgument } owner := "" - if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner); err != nil { + if _, err := tx.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), permission.IsRead(), permission.IsWrite(), owner, owner, provisioned); err != nil { return err } return nil @@ -1524,20 +1578,65 @@ func (a *Manager) Close() error { return a.db.Close() } -func (a *Manager) provisionUsers() error { - for _, user := range a.config.ProvisionedUsers { - if err := a.AddUser(user.Name, user.Hash, user.Role, true); err != nil && !errors.Is(err, ErrUserExists) { - return err - } +func (a *Manager) maybeProvisionUsersAndAccess() error { + if !a.config.ProvisionEnabled { + return nil } - for username, grants := range a.config.ProvisionedAccess { - for _, grant := range grants { - if err := a.AllowAccess(username, grant.TopicPattern, grant.Allow); err != nil { - return err + users, err := a.Users() + if err != nil { + return err + } + provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string { + return u.Name + }) + return execTx(a.db, func(tx *sql.Tx) error { + // Remove users that are provisioned, but not in the config anymore + for _, user := range users { + if user.Name == Everyone { + continue + } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { + log.Tag(tag).Info("Removing previously provisioned user %s", user.Name) + if err := a.removeUserTx(tx, user.Name); err != nil { + return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) + } } } - } - return nil + // Add or update provisioned users + for _, user := range a.config.ProvisionUsers { + if user.Name == Everyone { + continue + } + existingUser, exists := util.Find(users, func(u *User) bool { + return u.Name == user.Name + }) + if !exists { + log.Tag(tag).Info("Adding provisioned user %s", user.Name) + if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { + return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) + } + } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { + log.Tag(tag).Info("Updating provisioned user %s", user.Name) + if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { + return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) + } + if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { + return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) + } + } + } + // Remove and (re-)add provisioned grants + if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil { + return err + } + for username, grants := range a.config.ProvisionAccess { + for _, grant := range grants { + if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { + return err + } + } + } + return nil + }) } // toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards, @@ -1711,6 +1810,22 @@ func migrateFrom4(db *sql.DB) error { return tx.Commit() } +func migrateFrom5(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 5 to 6") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 6); err != nil { + return err + } + return tx.Commit() +} + func nullString(s string) sql.NullString { if s == "" { return sql.NullString{} @@ -1724,3 +1839,18 @@ func nullInt64(v int64) sql.NullInt64 { } return sql.NullInt64{Int64: v, Valid: true} } + +// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back. +func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + if err := f(tx); err != nil { + if e := tx.Rollback(); e != nil { + return err + } + return err + } + return tx.Commit() +} diff --git a/user/manager_test.go b/user/manager_test.go index b57c762c..42def63f 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -489,12 +489,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) { benGrants, err := a.Grants("ben") require.Nil(t, err) require.Equal(t, 1, len(benGrants)) - require.Equal(t, PermissionReadWrite, benGrants[0].Allow) + require.Equal(t, PermissionReadWrite, benGrants[0].Permission) everyoneGrants, err := a.Grants(Everyone) require.Nil(t, err) require.Equal(t, 1, len(everyoneGrants)) - require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow) + require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission) benReservations, err := a.Reservations("ben") require.Nil(t, err) @@ -1201,16 +1201,16 @@ func TestMigrationFrom1(t *testing.T) { require.NotEqual(t, ben.SyncTopic, phil.SyncTopic) require.Equal(t, 2, len(benGrants)) require.Equal(t, "secret", benGrants[0].TopicPattern) - require.Equal(t, PermissionRead, benGrants[0].Allow) + require.Equal(t, PermissionRead, benGrants[0].Permission) require.Equal(t, "stats", benGrants[1].TopicPattern) - require.Equal(t, PermissionReadWrite, benGrants[1].Allow) + require.Equal(t, PermissionReadWrite, benGrants[1].Permission) require.Equal(t, "u_everyone", everyone.ID) require.Equal(t, Everyone, everyone.Name) require.Equal(t, RoleAnonymous, everyone.Role) require.Equal(t, 1, len(everyoneGrants)) require.Equal(t, "stats", everyoneGrants[0].TopicPattern) - require.Equal(t, PermissionRead, everyoneGrants[0].Allow) + require.Equal(t, PermissionRead, everyoneGrants[0].Permission) } func TestMigrationFrom4(t *testing.T) { diff --git a/user/types.go b/user/types.go index 6f6b1f69..90eeefce 100644 --- a/user/types.go +++ b/user/types.go @@ -12,17 +12,18 @@ import ( // User is a struct that represents a user type User struct { - ID string - Name string - Hash string // password hash (bcrypt) - Token string // Only set if token was used to log in - Role Role - Prefs *Prefs - Tier *Tier - Stats *Stats - Billing *Billing - SyncTopic string - Deleted bool + ID string + Name string + Hash string // Password hash (bcrypt) + Token string // Only set if token was used to log in + Role Role + Prefs *Prefs + Tier *Tier + Stats *Stats + Billing *Billing + SyncTopic string + Provisioned bool // Whether the user was provisioned by the config file + Deleted bool // Whether the user was soft-deleted } // TierID returns the ID of the User.Tier, or an empty string if the user has no tier, @@ -148,7 +149,8 @@ type Billing struct { // Grant is a struct that represents an access control entry to a topic by a user type Grant struct { TopicPattern string // May include wildcard (*) - Allow Permission + Permission Permission + Provisioned bool // Whether the grant was provisioned by the config file } // Reservation is a struct that represents the ownership over a topic by a user diff --git a/util/util.go b/util/util.go index 73b227af..3648e3a4 100644 --- a/util/util.go +++ b/util/util.go @@ -120,6 +120,18 @@ func Filter[T any](slice []T, f func(T) bool) []T { return result } +// Find returns the first element in the slice that satisfies the given function, and a boolean indicating +// whether such an element was found. If no element is found, it returns the zero value of T and false. +func Find[T any](slice []T, f func(T) bool) (T, bool) { + for _, v := range slice { + if f(v) { + return v, true + } + } + var zero T + return zero, false +} + // RandomString returns a random string with a given length func RandomString(length int) string { return RandomStringPrefix("", length) From ef275ac0c189fdf2191717d3f9fa7951cb1041ce Mon Sep 17 00:00:00 2001 From: Emma Date: Tue, 22 Jul 2025 11:54:06 +0200 Subject: [PATCH 59/87] Add Ntfy Desktop to integrations --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index 23c5f9e9..01c415d5 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -96,6 +96,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies. - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. - [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails +- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++. ## Projects + scripts From 4457e9e26ff82d290ff82e8e8446ee5066be09db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 11:16:33 +0200 Subject: [PATCH 60/87] Migration --- user/manager.go | 81 ++++++++++++++++++++++++++--- user/manager_test.go | 119 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 173 insertions(+), 27 deletions(-) diff --git a/user/manager.go b/user/manager.go index f2f4875d..09db145e 100644 --- a/user/manager.go +++ b/user/manager.go @@ -316,7 +316,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 5 + currentSchemaVersion = 6 insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -434,11 +434,78 @@ const ( // 5 -> 6 migrate5To6UpdateQueries = ` - ALTER TABLE user ADD COLUMN provisioned INT NOT NULL DEFAULT (0); - ALTER TABLE user ALTER COLUMN provisioned DROP DEFAULT; + PRAGMA foreign_keys=off; - ALTER TABLE user_access ADD COLUMN provisioned INT NOT NULL DEFAULT (0); - ALTER TABLE user_access ALTER COLUMN provisioned DROP DEFAULT; + -- Alter user table: Add provisioned column + ALTER TABLE user RENAME TO user_old; + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + tier_id TEXT, + user TEXT NOT NULL, + pass TEXT NOT NULL, + role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL, + prefs JSON NOT NULL DEFAULT '{}', + sync_topic TEXT NOT NULL, + provisioned INT NOT NULL, + stats_messages INT NOT NULL DEFAULT (0), + stats_emails INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + stripe_subscription_status TEXT, + stripe_subscription_interval TEXT, + stripe_subscription_paid_until INT, + stripe_subscription_cancel_at INT, + created INT NOT NULL, + deleted INT, + FOREIGN KEY (tier_id) REFERENCES tier (id) + ); + INSERT INTO user + SELECT + id, + tier_id, + user, + pass, + role, + prefs, + sync_topic, + 0, + stats_messages, + stats_emails, + stats_calls, + stripe_customer_id, + stripe_subscription_id, + stripe_subscription_status, + stripe_subscription_interval, + stripe_subscription_paid_until, + stripe_subscription_cancel_at, + created, deleted + FROM user_old; + DROP TABLE user_old; + + -- Alter user_access table: Add provisioned column + ALTER TABLE user_access RENAME TO user_access_old; + CREATE TABLE user_access ( + user_id TEXT NOT NULL, + topic TEXT NOT NULL, + read INT NOT NULL, + write INT NOT NULL, + owner_user_id INT, + provisioned INTEGER NOT NULL, + PRIMARY KEY (user_id, topic), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE + ); + INSERT INTO user_access SELECT *, 0 FROM user_access_old; + DROP TABLE user_access_old; + + -- Recreate indices + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); + CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); + + -- Re-enable foreign keys + PRAGMA foreign_keys=on; ` ) @@ -1422,10 +1489,10 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss return err } defer tx.Rollback() - if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username); err != nil { + if _, err := tx.Exec(upsertUserAccessQuery, username, escapeUnderscore(topic), true, true, username, username, false); err != nil { return err } - if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username); err != nil { + if _, err := tx.Exec(upsertUserAccessQuery, Everyone, escapeUnderscore(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil { return err } return tx.Commit() diff --git a/user/manager_test.go b/user/manager_test.go index 42def63f..c2887ff3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -52,10 +52,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { benGrants, err := a.Grants("ben") require.Nil(t, err) require.Equal(t, []Grant{ - {"everyonewrite", PermissionDenyAll}, - {"mytopic", PermissionReadWrite}, - {"writeme", PermissionWrite}, - {"readme", PermissionRead}, + {"everyonewrite", PermissionDenyAll, false}, + {"mytopic", PermissionReadWrite, false}, + {"writeme", PermissionWrite, false}, + {"readme", PermissionRead, false}, }, benGrants) john, err := a.Authenticate("john", "john") @@ -67,10 +67,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { johnGrants, err := a.Grants("john") require.Nil(t, err) require.Equal(t, []Grant{ - {"mytopic_deny*", PermissionDenyAll}, - {"mytopic_ro*", PermissionRead}, - {"mytopic*", PermissionReadWrite}, - {"*", PermissionRead}, + {"mytopic_deny*", PermissionDenyAll, false}, + {"mytopic_ro*", PermissionRead, false}, + {"mytopic*", PermissionReadWrite, false}, + {"*", PermissionRead, false}, }, johnGrants) notben, err := a.Authenticate("ben", "this is wrong") @@ -277,10 +277,10 @@ func TestManager_UserManagement(t *testing.T) { benGrants, err := a.Grants("ben") require.Nil(t, err) require.Equal(t, []Grant{ - {"everyonewrite", PermissionDenyAll}, - {"mytopic", PermissionReadWrite}, - {"writeme", PermissionWrite}, - {"readme", PermissionRead}, + {"everyonewrite", PermissionDenyAll, false}, + {"mytopic", PermissionReadWrite, false}, + {"writeme", PermissionWrite, false}, + {"readme", PermissionRead, false}, }, benGrants) everyone, err := a.User(Everyone) @@ -292,8 +292,8 @@ func TestManager_UserManagement(t *testing.T) { everyoneGrants, err := a.Grants(Everyone) require.Nil(t, err) require.Equal(t, []Grant{ - {"everyonewrite", PermissionReadWrite}, - {"announcements", PermissionRead}, + {"everyonewrite", PermissionReadWrite, false}, + {"announcements", PermissionRead, false}, }, everyoneGrants) // Ben: Before revoking @@ -1099,19 +1099,98 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) { func TestManager_WithProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ - Filename: f, - DefaultAccess: PermissionReadWrite, - ProvisionedUsers: []*User{ - {Name: "phil", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + Filename: f, + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + ProvisionUsers: []*User{ + {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + }, + ProvisionAccess: map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + {TopicPattern: "secret", Permission: PermissionRead}, + }, }, } a, err := NewManager(conf) require.Nil(t, err) + + // Manually add user + require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false)) + + // Check that the provisioned users are there users, err := a.Users() require.Nil(t, err) - for _, u := range users { - fmt.Println(u.ID, u.Name, u.Role) + require.Len(t, users, 4) + + require.Equal(t, "philadmin", users[0].Name) + require.Equal(t, RoleAdmin, users[0].Role) + + require.Equal(t, "philmanual", users[1].Name) + require.Equal(t, RoleUser, users[1].Role) + + grants, err := a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, "philuser", users[2].Name) + require.Equal(t, RoleUser, users[2].Role) + require.Equal(t, 2, len(grants)) + require.Equal(t, "secret", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.Equal(t, "stats", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + + require.Equal(t, "*", users[3].Name) + + // Re-open the DB (second app start) + require.Nil(t, a.db.Close()) + conf.ProvisionUsers = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } + conf.ProvisionAccess = map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats12", Permission: PermissionReadWrite}, + {TopicPattern: "secret12", Permission: PermissionRead}, + }, + } + a, err = NewManager(conf) + require.Nil(t, err) + + // Check that the provisioned users are there + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 3) + + require.Equal(t, "philmanual", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, "philuser", users[1].Name) + require.Equal(t, RoleUser, users[1].Role) + require.Equal(t, 2, len(grants)) + require.Equal(t, "secret12", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.Equal(t, "stats12", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + + require.Equal(t, "*", users[2].Name) + + // Re-open the DB again (third app start) + require.Nil(t, a.db.Close()) + conf.ProvisionUsers = []*User{} + conf.ProvisionAccess = map[string][]*Grant{} + a, err = NewManager(conf) + require.Nil(t, err) + + // Check that the provisioned users are there + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + + require.Equal(t, "philmanual", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "*", users[1].Name) } func TestToFromSQLWildcard(t *testing.T) { From f99801a2e6d8c7675fd624ff8e16075dce734f4f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 12:14:21 +0200 Subject: [PATCH 61/87] Add "ntfy user hash" --- cmd/serve.go | 4 ++-- cmd/user.go | 33 +++++++++++++++++++++++++++++++++ user/manager.go | 29 ++++++++++++++++++++++------- user/manager_test.go | 4 ++-- user/types.go | 9 +++++++++ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index ef37ee6f..882debdc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -543,8 +543,8 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { role := user.Role(strings.TrimSpace(parts[2])) if !user.AllowedUsername(username) { return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine) - } else if passwordHash == "" { - return nil, fmt.Errorf("invalid auth-provision-users: %s, password hash cannot be empty", userLine) + } else if err := user.AllowedPasswordHash(passwordHash); err != nil { + return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error()) } else if !user.AllowedRole(role) { return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } diff --git a/cmd/user.go b/cmd/user.go index 0a6e24a1..49504a94 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -133,6 +133,22 @@ as messages per day, attachment file sizes, etc. Example: ntfy user change-tier phil pro # Change tier to "pro" for user "phil" ntfy user change-tier phil - # Remove tier from user "phil" entirely +`, + }, + { + Name: "hash", + Usage: "Create password hash for a predefined user", + UsageText: "ntfy user hash", + Action: execUserHash, + Description: `Asks for a password and creates a bcrypt password hash. + +This command is useful to create a password hash for a user, which can then be used +for predefined users in the server config file, in auth-provision-users. + +Example: + $ ntfy user hash + (asks for password and confirmation) + $2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C `, }, { @@ -289,6 +305,23 @@ func execUserChangeRole(c *cli.Context) error { return nil } +func execUserHash(c *cli.Context) error { + manager, err := createUserManager(c) + if err != nil { + return err + } + password, err := readPasswordAndConfirm(c) + if err != nil { + return err + } + hash, err := manager.HashPassword(password) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + fmt.Fprintf(c.App.Writer, "%s\n", string(hash)) + return nil +} + func execUserChangeTier(c *cli.Context) error { username := c.Args().Get(0) tier := c.Args().Get(1) diff --git a/user/manager.go b/user/manager.go index 09db145e..ecef8747 100644 --- a/user/manager.go +++ b/user/manager.go @@ -981,12 +981,15 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha if !AllowedUsername(username) || !AllowedRole(role) { return ErrInvalidArgument } - var hash []byte + var hash string var err error = nil if hashed { - hash = []byte(password) + hash = password + if err := AllowedPasswordHash(hash); err != nil { + return err + } } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1328,12 +1331,15 @@ func (a *Manager) ChangePassword(username, password string, hashed bool) error { } func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed bool) error { - var hash []byte + var hash string var err error if hashed { - hash = []byte(password) + hash = password + if err := AllowedPasswordHash(hash); err != nil { + return err + } } else { - hash, err = bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) + hash, err = a.HashPassword(password) if err != nil { return err } @@ -1640,6 +1646,15 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { }, nil } +// HashPassword hashes the given password using bcrypt with the configured cost +func (a *Manager) HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), a.config.BcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + // Close closes the underlying database func (a *Manager) Close() error { return a.db.Close() @@ -1681,7 +1696,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } - } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { + } else if existingUser.Provisioned && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) { log.Tag(tag).Info("Updating provisioned user %s", user.Name) if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) diff --git a/user/manager_test.go b/user/manager_test.go index c2887ff3..94bd1b97 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -340,7 +340,7 @@ func TestManager_UserManagement(t *testing.T) { func TestManager_ChangePassword(t *testing.T) { a := newTestManager(t, PermissionDenyAll) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false)) - require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) + require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true)) _, err := a.Authenticate("phil", "phil") require.Nil(t, err) @@ -354,7 +354,7 @@ func TestManager_ChangePassword(t *testing.T) { _, err = a.Authenticate("phil", "newpass") require.Nil(t, err) - require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) + require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true)) _, err = a.Authenticate("jane", "jane") require.Equal(t, ErrUnauthenticated, err) _, err = a.Authenticate("jane", "newpass") diff --git a/user/types.go b/user/types.go index 90eeefce..aaf77d1f 100644 --- a/user/types.go +++ b/user/types.go @@ -274,6 +274,14 @@ func AllowedTier(tier string) bool { return allowedTierRegex.MatchString(tier) } +// AllowedPasswordHash checks if the given password hash is a valid bcrypt hash +func AllowedPasswordHash(hash string) error { + if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") { + return ErrPasswordHashInvalid + } + return nil +} + // Error constants used by the package var ( ErrUnauthenticated = errors.New("unauthenticated") @@ -281,6 +289,7 @@ var ( ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") ErrUserExists = errors.New("user already exists") + ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") ErrPhoneNumberNotFound = errors.New("phone number not found") From 141ddb3a5187a7e3281f99e96d7e11bf09871388 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 26 Jul 2025 12:20:11 +0200 Subject: [PATCH 62/87] Comments --- user/manager.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user/manager.go b/user/manager.go index ecef8747..70d16370 100644 --- a/user/manager.go +++ b/user/manager.go @@ -529,11 +529,12 @@ type Manager struct { mu sync.Mutex } +// Config holds the configuration for the user Manager type Config struct { Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches - ProvisionEnabled bool // Enable auto-provisioning of users and access grants + ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands ProvisionUsers []*User // Predefined users to create on startup ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database From 0d36ab8af37760670358127d89e27c6ee102e154 Mon Sep 17 00:00:00 2001 From: Hunter Kehoe Date: Sun, 27 Jul 2025 00:01:51 -0600 Subject: [PATCH 63/87] allow newlines in in-line go templates --- server/server.go | 12 +++++++--- server/server_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/server/server.go b/server/server.go index f3d2ac51..0b7880cd 100644 --- a/server/server.go +++ b/server/server.go @@ -991,7 +991,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } - messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") + template = templateMode(readParam(r, "x-template", "template", "tpl")) + var messageStr string + if template.Enabled() && template.Name() == "" { + // don't convert "\n" to literal newline for inline templates + messageStr = readParam(r, "x-message", "message", "m") + } else { + messageStr = strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") + } if messageStr != "" { m.Message = messageStr } @@ -1033,7 +1040,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if markdown || strings.ToLower(contentType) == "text/markdown" { m.ContentType = "text/markdown" } - template = templateMode(readParam(r, "x-template", "template", "tpl")) unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! contentEncoding := readParam(r, "content-encoding") if unifiedpush || contentEncoding == "aes128gcm" { @@ -1198,7 +1204,7 @@ func (s *Server) renderTemplate(tpl string, source string) (string, error) { if err := t.Execute(limitWriter, data); err != nil { return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } - return strings.TrimSpace(buf.String()), nil + return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error { diff --git a/server/server_test.go b/server/server_test.go index 36bbae3f..41633dd5 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3069,6 +3069,61 @@ func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) { require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code) } +func TestServer_MessageTemplate_InlineNewlines(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{"New\nlines"}}`, + "X-Title": `{{"New\nlines"}}`, + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `New +lines`, m.Message) + require.Equal(t, `New +lines`, m.Title) +} + +func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{ + "X-Message": `{{.foo}}{{"\n"}}{{.food}}`, + "X-Title": `{{.food}}{{"\n"}}{{.foo}}`, + "X-Template": "1", + }) + + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar +bag`, m.Message) + require.Equal(t, `bag +bar`, m.Title) +} + +func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) { + t.Parallel() + c := newTestConfig(t) + c.TemplateDir = t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(` +title: | + {{.food}}{{"\n"}}{{.foo}} +message: | + {{.foo}}{{"\n"}}{{.food}} +`), 0644)) + s := newTestServer(t, c) + response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil) + fmt.Println(response.Body.String()) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.Equal(t, `bar +bag`, m.Message) + require.Equal(t, `bag +bar`, m.Title) +} + var ( //go:embed testdata/webhook_github_comment_created.json githubCommentCreatedJSON string From 1b394e9bb8105107d14e3f56a8f23cbefe73a4b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=AE=A4=E0=AE=AE=E0=AE=BF=E0=AE=B4=E0=AF=8D=E0=AE=A8?= =?UTF-8?q?=E0=AF=87=E0=AE=B0=E0=AE=AE=E0=AF=8D?= Date: Sat, 26 Jul 2025 07:39:33 +0200 Subject: [PATCH 64/87] Translated using Weblate (Tamil) Currently translated at 100.0% (405 of 405 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ta/ --- web/public/static/langs/ta.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/public/static/langs/ta.json b/web/public/static/langs/ta.json index 11635c91..b822107d 100644 --- a/web/public/static/langs/ta.json +++ b/web/public/static/langs/ta.json @@ -13,7 +13,7 @@ "nav_button_documentation": "ஆவணப்படுத்துதல்", "nav_button_publish_message": "அறிவிப்பை வெளியிடுங்கள்", "alert_not_supported_description": "உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை", - "alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது அறிவிப்புகள் பநிஇ இன் வரம்பு.", + "alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இதுஅறிவிப்புகள் பநிஇ இன் வரம்பு.", "notifications_list": "அறிவிப்புகள் பட்டியல்", "notifications_delete": "நீக்கு", "notifications_copied_to_clipboard": "இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது", @@ -76,7 +76,7 @@ "publish_dialog_chip_email_label": "மின்னஞ்சலுக்கு அனுப்பவும்", "publish_dialog_chip_call_no_verified_numbers_tooltip": "சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை", "publish_dialog_chip_attach_url_label": "முகவரி மூலம் கோப்பை இணைக்கவும்", - "publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து ஆவணங்கள் ஐப் பார்க்கவும்.", + "publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து ஆவணங்கள் ஐப் பார்க்கவும்.", "publish_dialog_chip_attach_file_label": "உள்ளக கோப்பை இணைக்கவும்", "publish_dialog_chip_delay_label": "நேரந்தவறுகை வழங்கல்", "publish_dialog_chip_topic_label": "தலைப்பை மாற்றவும்", @@ -133,10 +133,10 @@ "account_usage_cannot_create_portal_session": "பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை", "account_delete_title": "கணக்கை நீக்கு", "account_delete_description": "உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்", - "account_upgrade_dialog_cancel_warning": "இது உங்கள் சந்தாவை ரத்துசெய்யும் , மேலும் உங்கள் கணக்கை {{date} at இல் தரமிறக்குகிறது. அந்த தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாக சேமிக்கப்பட்ட செய்திகளும் நீக்கப்படும் .", + "account_upgrade_dialog_cancel_warning": "இது உங்கள் சந்தாவை ரத்துசெய்யும் , மேலும் உங்கள் கணக்கை {{date}} இல் தரமிறக்குகிறது. அந்தத் தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாகச் சேமிக்கப்பட்ட செய்திகளும் நீக்கப்படும் .", "account_upgrade_dialog_proration_info": " புரோரேசன் : கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு உடனடியாக கட்டணம் வசூலிக்கப்படும் . குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.", - "account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு . <இணைப்பு> அமைப்புகள் இல் முன்பதிவுகளை அகற்றலாம்.", - "account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு ஐ நீக்கவும். <இணைப்பு> அமைப்புகள் இல் முன்பதிவுகளை அகற்றலாம்.", + "account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு . அமைப்புகள் இல் முன்பதிவுகளை அகற்றலாம்.", + "account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு ஐ நீக்கவும். அமைப்புகள் இல் முன்பதிவுகளை அகற்றலாம்.", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்", "account_upgrade_dialog_tier_features_no_reservations": "ஒதுக்கப்பட்ட தலைப்புகள் இல்லை", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} நாள்தோறும் செய்தி", @@ -153,14 +153,14 @@ "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.", "account_upgrade_dialog_tier_selected_label": "தேர்ந்தெடுக்கப்பட்டது", "account_upgrade_dialog_tier_current_label": "மின்னோட்ட்ம், ஓட்டம்", - "account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <இணைப்பு> எங்களை தொடர்பு கொள்ளவும் நேரடியாக.", + "account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்களைத் தொடர்பு கொள்ளவும் நேரடியாக.", "account_upgrade_dialog_button_cancel": "ரத்துசெய்", - "account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <இணைப்பு> வலைத்தளம் ஐப் பார்க்கவும்.", + "account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் வலைத்தளம் ஐப் பார்க்கவும்.", "account_upgrade_dialog_button_redirect_signup": "இப்போது பதிவுபெறுக", "account_upgrade_dialog_button_pay_now": "இப்போது பணம் செலுத்தி குழுசேரவும்", "account_upgrade_dialog_button_cancel_subscription": "சந்தாவை ரத்துசெய்", "account_tokens_title": "டோக்கன்களை அணுகவும்", - "account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும் போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <இணைப்பு> ஆவணங்கள் ஐப் பாருங்கள்.", + "account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும்போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய ஆவணங்கள் ஐப் பாருங்கள்.", "account_upgrade_dialog_button_update_subscription": "சந்தாவைப் புதுப்பிக்கவும்", "account_tokens_table_token_header": "கிள்ளாக்கு", "account_tokens_table_label_header": "சிட்டை", @@ -216,7 +216,7 @@ "prefs_notifications_web_push_title": "பின்னணி அறிவிப்புகள்", "prefs_notifications_web_push_enabled_description": "வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)", "prefs_notifications_web_push_disabled_description": "வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)", - "prefs_notifications_web_push_enabled": "{{server} க்கு க்கு இயக்கப்பட்டது", + "prefs_notifications_web_push_enabled": "{{server}} க்கு இயக்கப்பட்டது", "prefs_notifications_web_push_disabled": "முடக்கப்பட்டது", "prefs_users_title": "பயனர்களை நிர்வகிக்கவும்", "prefs_users_description": "உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.", @@ -271,7 +271,7 @@ "priority_max": "அதிகபட்சம்", "priority_default": "இயல்புநிலை", "error_boundary_title": "ஓ, NTFY செயலிழந்தது", - "error_boundary_description": "இது வெளிப்படையாக நடக்கக்கூடாது. இதைப் பற்றி மிகவும் வருந்துகிறேன். .", + "error_boundary_description": "இது நிச்சயமாக நடக்கக் கூடாது. இதுகுறித்து மிகவும் வருந்துகிறேன்.
உங்களிடம் ஒரு நிமிடம் இருந்தால், தயவுசெய்து இதை GitHub இல் புகாரளிக்கவும், அல்லது Discord அல்லது Matrix வழியாக எங்களுக்குத் தெரியப்படுத்தவும்.", "error_boundary_button_copy_stack_trace": "அடுக்கு சுவடு நகலெடுக்கவும்", "error_boundary_button_reload_ntfy": "Ntfy ஐ மீண்டும் ஏற்றவும்", "error_boundary_stack_trace": "ச்டாக் சுவடு", @@ -349,7 +349,7 @@ "notifications_no_subscriptions_title": "உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.", "notifications_no_subscriptions_description": "ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \"{{linktext}}\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.", "notifications_example": "எடுத்துக்காட்டு", - "notifications_more_details": "மேலும் தகவலுக்கு, வலைத்தளம் அல்லது ஆவணங்கள் ஐப் பாருங்கள்.", + "notifications_more_details": "மேலும் தகவலுக்கு, வலைத்தளம் அல்லது ஆவணங்கள் ஐப் பாருங்கள்.", "display_name_dialog_title": "காட்சி பெயரை மாற்றவும்", "display_name_dialog_description": "சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.", "display_name_dialog_placeholder": "காட்சி பெயர்", @@ -399,7 +399,7 @@ "account_upgrade_dialog_interval_yearly_discount_save_up_to": "{{discount}}% வரை சேமிக்கவும்", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு", "prefs_users_add_button": "பயனரைச் சேர்க்கவும்", - "error_boundary_unsupported_indexeddb_description": "NTFY வலை பயன்பாட்டிற்கு செயல்பட குறியீட்டு தேவை, மற்றும் உங்கள் உலாவி தனிப்பட்ட உலாவல் பயன்முறையில் IndexEDDB ஐ ஆதரிக்காது. எப்படியிருந்தாலும் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்பாடு, ஏனென்றால் அனைத்தும் உலாவி சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இந்த அறிவிலிமையம் இதழில் இல் பற்றி நீங்கள் மேலும் படிக்கலாம் அல்லது டிச்கார்ட் அல்லது மேட்ரிக்ச் இல் எங்களுடன் பேசலாம்.", + "error_boundary_unsupported_indexeddb_description": "ntfy வலை பயன்பாடு செயல்பட IndexedDB தேவை, மேலும் உங்கள் உலாவித் தனிப்பட்ட உலாவல் பயன்முறையில் IndexedDB ஐ ஆதரிக்காது.

இது துரதிர்ஷ்டவசமானது என்றாலும், ntfy வலை பயன்பாட்டைத் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படுத்துவது உண்மையில் அர்த்தமற்றது, ஏனெனில் அனைத்தும் உலாவிச் சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இதைப் பற்றி நீங்கள் இந்த GitHub சிக்கலில் மேலும் படிக்கலாம், அல்லது Discord அல்லது Matrix இல் எங்களுடன் பேசலாம்.", "web_push_subscription_expiring_title": "அறிவிப்புகள் இடைநிறுத்தப்படும்", "web_push_subscription_expiring_body": "தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்", "web_push_unknown_notification_title": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது", From 1470afb71572f7e95597285eb4d1dac40b69979f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 10:15:48 +0200 Subject: [PATCH 65/87] Make templateMode more understandable --- server/server.go | 14 ++++++-------- server/types.go | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/server/server.go b/server/server.go index 0b7880cd..ac7b10dc 100644 --- a/server/server.go +++ b/server/server.go @@ -992,12 +992,10 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } template = templateMode(readParam(r, "x-template", "template", "tpl")) - var messageStr string - if template.Enabled() && template.Name() == "" { - // don't convert "\n" to literal newline for inline templates - messageStr = readParam(r, "x-message", "message", "m") - } else { - messageStr = strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") + messageStr := readParam(r, "x-message", "message", "m") + if !template.InlineMode() { + // Convert "\n" to literal newline everything but inline mode + messageStr = strings.ReplaceAll(messageStr, "\\n", "\n") } if messageStr != "" { m.Message = messageStr @@ -1125,8 +1123,8 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return errHTTPEntityTooLargeJSONBody } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) - if templateName := template.Name(); templateName != "" { - if err := s.renderTemplateFromFile(m, templateName, peekedBody); err != nil { + if template.FileMode() { + if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { return err } } else { diff --git a/server/types.go b/server/types.go index ea6b8615..65492e46 100644 --- a/server/types.go +++ b/server/types.go @@ -245,19 +245,46 @@ func (q *queryFilter) Pass(msg *message) bool { return true } +// templateMode represents the mode in which templates are used +// +// It can be +// - empty: templating is disabled +// - a boolean string (yes/1/true/no/0/false): inline-templating mode +// - a filename (e.g. grafana): template mode with a file type templateMode string +// Enabled returns true if templating is enabled func (t templateMode) Enabled() bool { return t != "" } -func (t templateMode) Name() string { - if isBoolValue(string(t)) { - return "" - } - return string(t) +// InlineMode returns true if inline-templating mode is enabled +func (t templateMode) InlineMode() bool { + return t.Enabled() && isBoolValue(string(t)) } +// FileMode returns true if file-templating mode is enabled +func (t templateMode) FileMode() bool { + return t.Enabled() && !isBoolValue(string(t)) +} + +// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise +func (t templateMode) FileName() string { + if t.FileMode() { + return string(t) + } + return "" +} + +// templateFile represents a template file with title and message +// It is used for file-based templates, e.g. grafana, influxdb, etc. +// +// Example YAML: +// +// title: "Alert: {{ .Title }}" +// message: | +// This is a {{ .Type }} alert. +// It can be multiline. type templateFile struct { Title *string `yaml:"title"` Message *string `yaml:"message"` From f3c67f1d716f6ee50af89cfd51a908d962bdc73a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 11:02:34 +0200 Subject: [PATCH 66/87] Refuse to update manually created users --- cmd/serve.go | 4 ++-- server/config.go | 4 ++-- server/server.go | 4 ++-- user/manager.go | 18 +++++++++++------- user/manager_test.go | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 882debdc..7e7e56e1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -416,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionedUsers = authProvisionUsers - conf.AuthProvisionedAccess = authProvisionAccess + conf.AuthProvisionUsers = authProvisionUsers + conf.AuthProvisionAccess = authProvisionAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit diff --git a/server/config.go b/server/config.go index 86971e47..5cf0b035 100644 --- a/server/config.go +++ b/server/config.go @@ -95,8 +95,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthProvisionedUsers []*user.User - AuthProvisionedAccess map[string][]*user.Grant + AuthProvisionUsers []*user.User + AuthProvisionAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 4fcd9ba3..dbe61905 100644 --- a/server/server.go +++ b/server/server.go @@ -201,8 +201,8 @@ func New(conf *Config) (*Server, error) { StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access - ProvisionUsers: conf.AuthProvisionedUsers, - ProvisionAccess: conf.AuthProvisionedAccess, + ProvisionUsers: conf.AuthProvisionUsers, + ProvisionAccess: conf.AuthProvisionAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 70d16370..2e176450 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1697,13 +1697,17 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } - } else if existingUser.Provisioned && (existingUser.Hash != user.Hash || existingUser.Role != user.Role) { - log.Tag(tag).Info("Updating provisioned user %s", user.Name) - if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { - return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) - } - if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { - return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) + } else { + if !existingUser.Provisioned { + log.Tag(tag).Warn("Refusing to update manually user %s", user.Name) + } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { + log.Tag(tag).Info("Updating provisioned user %s", user.Name) + if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { + return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) + } + if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { + return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) + } } } } diff --git a/user/manager_test.go b/user/manager_test.go index 94bd1b97..2ce078f3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1193,6 +1193,39 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Equal(t, "*", users[1].Name) } +func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { + f := filepath.Join(t.TempDir(), "user.db") + conf := &Config{ + Filename: f, + DefaultAccess: PermissionReadWrite, + ProvisionEnabled: true, + ProvisionUsers: []*User{}, + ProvisionAccess: map[string][]*Grant{}, + } + a, err := NewManager(conf) + require.Nil(t, err) + + // Manually add user + require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) + + // Re-open the DB (second app start) + require.Nil(t, a.db.Close()) + conf.ProvisionUsers = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, + } + conf.ProvisionAccess = map[string][]*Grant{} + a, err = NewManager(conf) + require.Nil(t, err) + + // Check that the provisioned users are there + users, err := a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) // Should not have been updated + require.NotEqual(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) +} + func TestToFromSQLWildcard(t *testing.T) { require.Equal(t, "up%", toSQLWildcard("up*")) require.Equal(t, "up\\_%", toSQLWildcard("up_*")) From fe545423c518b42534652aebf4f127a062f09eb3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 12:10:16 +0200 Subject: [PATCH 67/87] Change to auth-(users|access), upgrade manually added users to provision users --- cmd/serve.go | 40 ++++++++++++++++++++-------------------- server/config.go | 4 ++-- server/server.go | 4 ++-- user/manager.go | 38 ++++++++++++++++++++++++++++---------- user/manager_test.go | 20 ++++++++++---------- 5 files changed, 62 insertions(+), 44 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 7e7e56e1..dc503ccc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -48,8 +48,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), - altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-users", Aliases: []string{"auth_provision_users"}, EnvVars: []string{"NTFY_AUTH_PROVISION_USERS"}, Usage: "pre-provisioned declarative users"}), - altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-provision-access", Aliases: []string{"auth_provision_access"}, EnvVars: []string{"NTFY_AUTH_PROVISION_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -156,8 +156,8 @@ func execServe(c *cli.Context) error { authFile := c.String("auth-file") authStartupQueries := c.String("auth-startup-queries") authDefaultAccess := c.String("auth-default-access") - authProvisionUsersRaw := c.StringSlice("auth-provision-users") - authProvisionAccessRaw := c.StringSlice("auth-provision-access") + authUsersRaw := c.StringSlice("auth-users") + authAccessRaw := c.StringSlice("auth-access") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -353,11 +353,11 @@ func execServe(c *cli.Context) error { if err != nil { return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") } - authProvisionUsers, err := parseProvisionUsers(authProvisionUsersRaw) + authUsers, err := parseUsers(authUsersRaw) if err != nil { return err } - authProvisionAccess, err := parseProvisionAccess(authProvisionUsers, authProvisionAccessRaw) + authAccess, err := parseAccess(authUsers, authAccessRaw) if err != nil { return err } @@ -416,8 +416,8 @@ func execServe(c *cli.Context) error { conf.AuthFile = authFile conf.AuthStartupQueries = authStartupQueries conf.AuthDefault = authDefault - conf.AuthProvisionUsers = authProvisionUsers - conf.AuthProvisionAccess = authProvisionAccess + conf.AuthUsers = authUsers + conf.AuthAccess = authAccess conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -531,22 +531,22 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { return } -func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { +func parseUsers(usersRaw []string) ([]*user.User, error) { provisionUsers := make([]*user.User, 0) for _, userLine := range usersRaw { parts := strings.Split(userLine, ":") if len(parts) != 3 { - return nil, fmt.Errorf("invalid auth-provision-users: %s, expected format: 'name:hash:role'", userLine) + return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine) } username := strings.TrimSpace(parts[0]) passwordHash := strings.TrimSpace(parts[1]) role := user.Role(strings.TrimSpace(parts[2])) if !user.AllowedUsername(username) { - return nil, fmt.Errorf("invalid auth-provision-users: %s, username invalid", userLine) + return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) } else if err := user.AllowedPasswordHash(passwordHash); err != nil { - return nil, fmt.Errorf("invalid auth-provision-users: %s, %s", userLine, err.Error()) + return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) } else if !user.AllowedRole(role) { - return nil, fmt.Errorf("invalid auth-provision-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) + return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } provisionUsers = append(provisionUsers, &user.User{ Name: username, @@ -558,12 +558,12 @@ func parseProvisionUsers(usersRaw []string) ([]*user.User, error) { return provisionUsers, nil } -func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { +func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { access := make(map[string][]*user.Grant) for _, accessLine := range provisionAccessRaw { parts := strings.Split(accessLine, ":") if len(parts) != 3 { - return nil, fmt.Errorf("invalid auth-provision-access: %s, expected format: 'user:topic:permission'", accessLine) + return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) } username := strings.TrimSpace(parts[0]) if username == userEveryone { @@ -574,20 +574,20 @@ func parseProvisionAccess(provisionUsers []*user.User, provisionAccessRaw []stri }) if username != user.Everyone { if !exists { - return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not provisioned", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username) } else if !user.AllowedUsername(username) { - return nil, fmt.Errorf("invalid auth-provision-access: %s, username %s invalid", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username) } else if provisionUser.Role != user.RoleUser { - return nil, fmt.Errorf("invalid auth-provision-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) + return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) } } topic := strings.TrimSpace(parts[1]) if !user.AllowedTopicPattern(topic) { - return nil, fmt.Errorf("invalid auth-provision-access: %s, topic pattern %s invalid", accessLine, topic) + return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic) } permission, err := user.ParsePermission(strings.TrimSpace(parts[2])) if err != nil { - return nil, fmt.Errorf("invalid auth-provision-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) + return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error()) } if _, exists := access[username]; !exists { access[username] = make([]*user.Grant, 0) diff --git a/server/config.go b/server/config.go index 5cf0b035..99d829b2 100644 --- a/server/config.go +++ b/server/config.go @@ -95,8 +95,8 @@ type Config struct { AuthFile string AuthStartupQueries string AuthDefault user.Permission - AuthProvisionUsers []*user.User - AuthProvisionAccess map[string][]*user.Grant + AuthUsers []*user.User + AuthAccess map[string][]*user.Grant AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index dbe61905..55fa3af7 100644 --- a/server/server.go +++ b/server/server.go @@ -201,8 +201,8 @@ func New(conf *Config) (*Server, error) { StartupQueries: conf.AuthStartupQueries, DefaultAccess: conf.AuthDefault, ProvisionEnabled: true, // Enable provisioning of users and access - ProvisionUsers: conf.AuthProvisionUsers, - ProvisionAccess: conf.AuthProvisionAccess, + Users: conf.AuthUsers, + Access: conf.AuthAccess, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/user/manager.go b/user/manager.go index 2e176450..5418f534 100644 --- a/user/manager.go +++ b/user/manager.go @@ -184,6 +184,7 @@ const ( selectUserCountQuery = `SELECT COUNT(*) FROM user` updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` + updateUserProvisionedQuery = `UPDATE user SET provisioned = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` @@ -535,8 +536,8 @@ type Config struct { StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers DefaultAccess Permission // Default permission if no ACL matches ProvisionEnabled bool // Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - ProvisionUsers []*User // Predefined users to create on startup - ProvisionAccess map[string][]*Grant // Predefined access grants to create on startup + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database BcryptCost int // Cost of generated passwords; lowering makes testing faster } @@ -1374,6 +1375,21 @@ func (a *Manager) changeRoleTx(tx *sql.Tx, username string, role Role) error { return nil } +// ChangeProvisioned changes the provisioned status of a user. This is used to mark users as +// provisioned. A provisioned user is a user defined in the config file. +func (a *Manager) ChangeProvisioned(username string, provisioned bool) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.changeProvisionedTx(tx, username, provisioned) + }) +} + +func (a *Manager) changeProvisionedTx(tx *sql.Tx, username string, provisioned bool) error { + if _, err := tx.Exec(updateUserProvisionedQuery, provisioned, username); err != nil { + return err + } + return nil +} + // ChangeTier changes a user's tier using the tier code. This function does not delete reservations, messages, // or attachments, even if the new tier has lower limits in this regard. That has to be done elsewhere. func (a *Manager) ChangeTier(username, tier string) error { @@ -1669,7 +1685,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if err != nil { return err } - provisionUsernames := util.Map(a.config.ProvisionUsers, func(u *User) string { + provisionUsernames := util.Map(a.config.Users, func(u *User) string { return u.Name }) return execTx(a.db, func(tx *sql.Tx) error { @@ -1678,14 +1694,13 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if user.Name == Everyone { continue } else if user.Provisioned && !util.Contains(provisionUsernames, user.Name) { - log.Tag(tag).Info("Removing previously provisioned user %s", user.Name) if err := a.removeUserTx(tx, user.Name); err != nil { return fmt.Errorf("failed to remove provisioned user %s: %v", user.Name, err) } } } // Add or update provisioned users - for _, user := range a.config.ProvisionUsers { + for _, user := range a.config.Users { if user.Name == Everyone { continue } @@ -1693,18 +1708,21 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { return u.Name == user.Name }) if !exists { - log.Tag(tag).Info("Adding provisioned user %s", user.Name) if err := a.addUserTx(tx, user.Name, user.Hash, user.Role, true, true); err != nil && !errors.Is(err, ErrUserExists) { return fmt.Errorf("failed to add provisioned user %s: %v", user.Name, err) } } else { if !existingUser.Provisioned { - log.Tag(tag).Warn("Refusing to update manually user %s", user.Name) - } else if existingUser.Hash != user.Hash || existingUser.Role != user.Role { - log.Tag(tag).Info("Updating provisioned user %s", user.Name) + if err := a.changeProvisionedTx(tx, user.Name, true); err != nil { + return fmt.Errorf("failed to change provisioned status for user %s: %v", user.Name, err) + } + } + if existingUser.Hash != user.Hash { if err := a.changePasswordTx(tx, user.Name, user.Hash, true); err != nil { return fmt.Errorf("failed to change password for provisioned user %s: %v", user.Name, err) } + } + if existingUser.Role != user.Role { if err := a.changeRoleTx(tx, user.Name, user.Role); err != nil { return fmt.Errorf("failed to change role for provisioned user %s: %v", user.Name, err) } @@ -1715,7 +1733,7 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { if _, err := tx.Exec(deleteUserAccessProvisionedQuery); err != nil { return err } - for username, grants := range a.config.ProvisionAccess { + for username, grants := range a.config.Access { for _, grant := range grants { if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err diff --git a/user/manager_test.go b/user/manager_test.go index 2ce078f3..d55726a3 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1102,11 +1102,11 @@ func TestManager_WithProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{ + Users: []*User{ {Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, {Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, }, - ProvisionAccess: map[string][]*Grant{ + Access: map[string][]*Grant{ "philuser": { {TopicPattern: "stats", Permission: PermissionReadWrite}, {TopicPattern: "secret", Permission: PermissionRead}, @@ -1144,10 +1144,10 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, } - conf.ProvisionAccess = map[string][]*Grant{ + conf.Access = map[string][]*Grant{ "philuser": { {TopicPattern: "stats12", Permission: PermissionReadWrite}, {TopicPattern: "secret12", Permission: PermissionRead}, @@ -1178,8 +1178,8 @@ func TestManager_WithProvisionedUsers(t *testing.T) { // Re-open the DB again (third app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{} - conf.ProvisionAccess = map[string][]*Grant{} + conf.Users = []*User{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err) @@ -1199,8 +1199,8 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, - ProvisionUsers: []*User{}, - ProvisionAccess: map[string][]*Grant{}, + Users: []*User{}, + Access: map[string][]*Grant{}, } a, err := NewManager(conf) require.Nil(t, err) @@ -1210,10 +1210,10 @@ func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { // Re-open the DB (second app start) require.Nil(t, a.db.Close()) - conf.ProvisionUsers = []*User{ + conf.Users = []*User{ {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, } - conf.ProvisionAccess = map[string][]*Grant{} + conf.Access = map[string][]*Grant{} a, err = NewManager(conf) require.Nil(t, err) From 2578236d8d156b1608afe9ad33a41187f5949eec Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 17:10:37 +0200 Subject: [PATCH 68/87] Docs --- docs/config.md | 60 +++++++++++++++++++++++++++++++++++++++++++---- server/server.yml | 6 ++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index be15c9fc..564478f7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -88,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`): NTFY_CACHE_FILE: /var/lib/ntfy/cache.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_DEFAULT_ACCESS: deny-all + NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' NTFY_BEHIND_PROXY: true NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ENABLE_LOGIN: true @@ -195,12 +196,20 @@ To set up auth, simply **configure the following two options**: * `auth-default-access` defines the default/fallback access if no access control entry is found; it can be set to `read-write` (default), `read-only`, `write-only` or `deny-all`. -Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command -lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these -commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user -accessing them has the right permissions. +Once configured, you can use the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles). +The `ntfy access` command and the `auth-access` option let you [modify the access control list](#access-control-list-acl) for specific users +and topic patterns. + +Both of these commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, +and only if the user accessing them has the right permissions. ### Users and roles +Users can be added to the ntfy user database in two different ways + +* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users. +* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key. + +#### Users via the CLI The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change passwords or roles (`user` or `admin`). In practice, you'll often just create one admin user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)). @@ -223,10 +232,45 @@ ntfy user change-role phil admin # Make user phil an admin ntfy user change-tier phil pro # Change phil's tier to "pro" ``` +#### Users via the config +As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in +the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to +deploy your ntfy server via Ansible without manually editing the database. + +The `auth-users` option is a list of users that are automatically created when the server starts. Each entry is defined +in the format `::`. + +Here's an example with two users: `phil` is an admin, `ben` is a regular user. + +=== "Declarative users in /etc/ntfy/server.yml" + ``` yaml + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" + - "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user" + ``` + +=== "Declarative users via env variables" + ``` + # Comma-separated list, use single quotes to avoid issues with the bcrypt hash + NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user' + ``` + +The bcrypt hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though +note that you're putting your password in an untrusted website). + +!!! important + Users added declaratively via the config file are marked in the database as "provisioned users". Removing users + from the config file will **delete them from the database** the next time ntfy is restarted. + + Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to + the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence + lead to the **deletion of that user**. + ### Access control list (ACL) The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**. Each entry represents the access permissions for a user to a specific topic or topic pattern. +#### ACL entries via the CLI The ACL can be displayed or modified with the `ntfy access` command: ``` @@ -282,6 +326,14 @@ User `ben` has three topic-specific entries. He can read, but not write to topic to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. +#### ACL entries via the config +Alternatively to the `ntfy access` command + ++# - auth-access is a list of access control entries that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". +# + + ### Access tokens In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may diff --git a/server/server.yml b/server/server.yml index 02af7383..0d748640 100644 --- a/server/server.yml +++ b/server/server.yml @@ -82,9 +82,9 @@ # set to "read-write" (default), "read-only", "write-only" or "deny-all". # - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable # WAL mode. This is similar to cache-startup-queries. See above for details. -# - auth-provision-users is a list of users that are automatically created when the server starts. -# Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" -# - auth-provision-access is a list of access control entries that are automatically created when the server starts. +# - auth-users is a list of users that are automatically created when the server starts. +# Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" +# - auth-access is a list of access control entries that are automatically created when the server starts. # Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". # # Debian/RPM package users: From 0e672286054d4623feb9deb718b3ab1b4e794d8a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 17:18:06 +0200 Subject: [PATCH 69/87] Docs --- docs/config.md | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/config.md b/docs/config.md index 564478f7..47dbb923 100644 --- a/docs/config.md +++ b/docs/config.md @@ -327,12 +327,37 @@ to topic `garagedoor` and all topics starting with the word `alerts` (wildcards) (called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics. #### ACL entries via the config -Alternatively to the `ntfy access` command +As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control +entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users` +option (see [users via the config](#users-via-the-config). -+# - auth-access is a list of access control entries that are automatically created when the server starts. -# Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". -# +The `auth-access` option is a list of access control entries that are automatically created when the server starts. +Each entry is defined in the format `::`. +Here's an example with several ACL entries: + +=== "Declarative ACL entries in /etc/ntfy/server.yml" + ``` yaml + auth-access: + - "phil:mytopic:rw" + - "ben:alerts-*:rw" + - "ben:system-logs:ro" + - "*:announcements:ro" # or: "everyone:announcements,ro" + ``` + +=== "Declarative ACL entries via env variables" + ``` + # Comma-separated list + NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro' + ``` + +The `` can be any existing user, or `everyone`/`*` for anonymous access. The `` can be a specific +topic name or a pattern with wildcards (`*`). The `` can be one of the following: + +* `read-write` or `rw`: Allows both publishing to and subscribing to the topic +* `read-only`, `read`, or `ro`: Allows only subscribing to the topic +* `write-only`, `write`, or `wo`: Allows only publishing to the topic +* `deny-all`, `deny`, or `none`: Denies all access to the topic ### Access tokens In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful From 07e9670a0966d16aca7967cc269a14b5bf4a13c3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 22:33:29 +0200 Subject: [PATCH 70/87] Fix bug in test --- cmd/access.go | 4 +-- docs/releases.md | 3 +- user/manager.go | 27 ++++++++++++---- user/manager_test.go | 77 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/cmd/access.go b/cmd/access.go index 10247b5f..f2916f51 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -197,7 +197,7 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error } provisioned := "" if u.Provisioned { - provisioned = ", provisioned user" + provisioned = ", server config" } fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned) if u.Role == user.RoleAdmin { @@ -206,7 +206,7 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error for _, grant := range grants { grantProvisioned := "" if grant.Provisioned { - grantProvisioned = ", provisioned access entry" + grantProvisioned = " (server config)" } if grant.Permission.IsReadWrite() { fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned) diff --git a/docs/releases.md b/docs/releases.md index 6171dcff..4f79f544 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1456,7 +1456,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Enhanced JSON webhook support via [pre-defined](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) +* [Declarative users and ACL entries](config.md#users-and-roles) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing) +* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390)) * Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/user/manager.go b/user/manager.go index 5418f534..36a22dd9 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1484,19 +1484,25 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is // empty) for an entire user. The parameter topicPattern may include wildcards (*). func (a *Manager) ResetAccess(username string, topicPattern string) error { + return execTx(a.db, func(tx *sql.Tx) error { + return a.resetAccessTx(tx, username, topicPattern) + }) +} + +func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error { if !AllowedUsername(username) && username != Everyone && username != "" { return ErrInvalidArgument } else if !AllowedTopicPattern(topicPattern) && topicPattern != "" { return ErrInvalidArgument } if username == "" && topicPattern == "" { - _, err := a.db.Exec(deleteAllAccessQuery, username) + _, err := tx.Exec(deleteAllAccessQuery, username) return err } else if topicPattern == "" { - _, err := a.db.Exec(deleteUserAccessQuery, username, username) + _, err := tx.Exec(deleteUserAccessQuery, username, username) return err } - _, err := a.db.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) + _, err := tx.Exec(deleteTopicAccessQuery, username, username, toSQLWildcard(topicPattern)) return err } @@ -1734,7 +1740,18 @@ func (a *Manager) maybeProvisionUsersAndAccess() error { return err } for username, grants := range a.config.Access { + user, exists := util.Find(a.config.Users, func(u *User) bool { + return u.Name == username + }) + if !exists && username != Everyone { + return fmt.Errorf("user %s is not a provisioned user, refusing to add ACL entry", username) + } else if user != nil && user.Role == RoleAdmin { + return fmt.Errorf("adding access control entries is not allowed for admin roles for user %s", username) + } for _, grant := range grants { + if err := a.resetAccessTx(tx, username, grant.TopicPattern); err != nil { + return fmt.Errorf("failed to reset access for user %s and topic %s: %v", username, grant.TopicPattern, err) + } if err := a.allowAccessTx(tx, username, grant.TopicPattern, grant.Permission, true); err != nil { return err } @@ -1951,10 +1968,8 @@ func execTx(db *sql.DB, f func(tx *sql.Tx) error) error { if err != nil { return err } + defer tx.Rollback() if err := f(tx); err != nil { - if e := tx.Rollback(); e != nil { - return err - } return err } return tx.Commit() diff --git a/user/manager_test.go b/user/manager_test.go index d55726a3..297263e9 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1193,37 +1193,86 @@ func TestManager_WithProvisionedUsers(t *testing.T) { require.Equal(t, "*", users[1].Name) } -func TestManager_DoNotUpdateNonProvisionedUsers(t *testing.T) { +func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) { f := filepath.Join(t.TempDir(), "user.db") conf := &Config{ Filename: f, DefaultAccess: PermissionReadWrite, ProvisionEnabled: true, Users: []*User{}, - Access: map[string][]*Grant{}, + Access: map[string][]*Grant{ + Everyone: { + {TopicPattern: "food", Permission: PermissionRead}, + }, + }, } a, err := NewManager(conf) require.Nil(t, err) // Manually add user require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false)) + require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite)) + require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite)) - // Re-open the DB (second app start) - require.Nil(t, a.db.Close()) - conf.Users = []*User{ - {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin}, - } - conf.Access = map[string][]*Grant{} - a, err = NewManager(conf) - require.Nil(t, err) - - // Check that the provisioned users are there users, err := a.Users() require.Nil(t, err) require.Len(t, users, 2) require.Equal(t, "philuser", users[0].Name) - require.Equal(t, RoleUser, users[0].Role) // Should not have been updated - require.NotEqual(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) + require.Equal(t, RoleUser, users[0].Role) + require.False(t, users[0].Provisioned) // Manually added + + grants, err := a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.False(t, grants[0].Provisioned) // Manually added + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added + + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Equal(t, 1, len(grants)) + require.Equal(t, "food", grants[0].TopicPattern) + require.Equal(t, PermissionRead, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Provisioned entry + + // Re-open the DB (second app start) + require.Nil(t, a.db.Close()) + conf.Users = []*User{ + {Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + } + conf.Access = map[string][]*Grant{ + "philuser": { + {TopicPattern: "stats", Permission: PermissionReadWrite}, + }, + } + a, err = NewManager(conf) + require.Nil(t, err) + + // Check that the user was "upgraded" to a provisioned user + users, err = a.Users() + require.Nil(t, err) + require.Len(t, users, 2) + require.Equal(t, "philuser", users[0].Name) + require.Equal(t, RoleUser, users[0].Role) + require.Equal(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash) + require.True(t, users[0].Provisioned) // Updated to provisioned! + + grants, err = a.Grants("philuser") + require.Nil(t, err) + require.Equal(t, 2, len(grants)) + require.Equal(t, "stats", grants[0].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[0].Permission) + require.True(t, grants[0].Provisioned) // Updated to provisioned! + require.Equal(t, "food", grants[1].TopicPattern) + require.Equal(t, PermissionReadWrite, grants[1].Permission) + require.False(t, grants[1].Provisioned) // Manually added grants stay! + + grants, err = a.Grants(Everyone) + require.Nil(t, err) + require.Empty(t, grants) } func TestToFromSQLWildcard(t *testing.T) { From 149c13e9d89cede63e105990e6f6fd88d8cf22de Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 27 Jul 2025 22:38:12 +0200 Subject: [PATCH 71/87] Update config to reference declarative users --- docs/config.md | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/docs/config.md b/docs/config.md index 47dbb923..587e8844 100644 --- a/docs/config.md +++ b/docs/config.md @@ -393,23 +393,17 @@ Once an access token is created, you can **use it to authenticate against the nt subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens). ### Example: Private instance -The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`: +The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`, +and to configure a single admin user in the `auth-users` section (see [Users via the config](#users-via-the-config)). === "/etc/ntfy/server.yml" ``` yaml auth-file: "/var/lib/ntfy/user.db" auth-default-access: "deny-all" + auth-users: + - "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin" ``` -After that, simply create an `admin` user: - -``` -$ ntfy user add --role=admin phil -password: mypass -confirm: mypass -user phil added with role admin -``` - Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example: From 23ec7702fce5690d1c0f55d61e8e02d5ba93d43a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 31 Jul 2025 07:08:35 +0200 Subject: [PATCH 72/87] Add "auth-tokens" --- cmd/serve.go | 56 ++++++++++-- cmd/token.go | 29 ++++-- server/config.go | 1 + server/server.go | 1 + server/server.yml | 2 + server/server_account.go | 2 +- server/server_account_test.go | 2 +- user/manager.go | 165 ++++++++++++++++++++++++---------- user/manager_test.go | 69 +++++++++----- user/types.go | 24 +++-- 10 files changed, 263 insertions(+), 88 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index dc503ccc..36bef4bd 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -50,6 +50,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), + altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), @@ -158,6 +159,7 @@ func execServe(c *cli.Context) error { authDefaultAccess := c.String("auth-default-access") authUsersRaw := c.StringSlice("auth-users") authAccessRaw := c.StringSlice("auth-access") + authTokensRaw := c.StringSlice("auth-tokens") attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") @@ -361,6 +363,10 @@ func execServe(c *cli.Context) error { if err != nil { return err } + authTokens, err := parseTokens(authUsers, authTokensRaw) + if err != nil { + return err + } // Special case: Unset default if listenHTTP == "-" { @@ -418,6 +424,7 @@ func execServe(c *cli.Context) error { conf.AuthDefault = authDefault conf.AuthUsers = authUsers conf.AuthAccess = authAccess + conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -532,7 +539,7 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) { } func parseUsers(usersRaw []string) ([]*user.User, error) { - provisionUsers := make([]*user.User, 0) + users := make([]*user.User, 0) for _, userLine := range usersRaw { parts := strings.Split(userLine, ":") if len(parts) != 3 { @@ -548,19 +555,19 @@ func parseUsers(usersRaw []string) ([]*user.User, error) { } else if !user.AllowedRole(role) { return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role) } - provisionUsers = append(provisionUsers, &user.User{ + users = append(users, &user.User{ Name: username, Hash: passwordHash, Role: role, Provisioned: true, }) } - return provisionUsers, nil + return users, nil } -func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[string][]*user.Grant, error) { +func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) { access := make(map[string][]*user.Grant) - for _, accessLine := range provisionAccessRaw { + for _, accessLine := range accessRaw { parts := strings.Split(accessLine, ":") if len(parts) != 3 { return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine) @@ -569,7 +576,7 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ if username == userEveryone { username = user.Everyone } - provisionUser, exists := util.Find(provisionUsers, func(u *user.User) bool { + u, exists := util.Find(users, func(u *user.User) bool { return u.Name == username }) if username != user.Everyone { @@ -577,7 +584,7 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username) } else if !user.AllowedUsername(username) { return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username) - } else if provisionUser.Role != user.RoleUser { + } else if u.Role != user.RoleUser { return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username) } } @@ -601,6 +608,41 @@ func parseAccess(provisionUsers []*user.User, provisionAccessRaw []string) (map[ return access, nil } +func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) { + tokens := make(map[string][]*user.Token) + for _, tokenLine := range tokensRaw { + parts := strings.Split(tokenLine, ":") + if len(parts) < 2 || len(parts) > 3 { + return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine) + } + username := strings.TrimSpace(parts[0]) + _, exists := util.Find(users, func(u *user.User) bool { + return u.Name == username + }) + if !exists { + return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username) + } else if !user.AllowedUsername(username) { + return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username) + } + token := strings.TrimSpace(parts[1]) + if !user.AllowedToken(token) { + return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token) + } + var label string + if len(parts) > 2 { + label = parts[2] + } + if _, exists := tokens[username]; !exists { + tokens[username] = make([]*user.Token, 0) + } + tokens[username] = append(tokens[username], &user.Token{ + Value: token, + Label: label, + }) + } + return tokens, nil +} + func reloadLogLevel(inputSource altsrc.InputSourceContext) error { newLevelStr, err := inputSource.String("log-level") if err != nil { diff --git a/cmd/token.go b/cmd/token.go index cb92a130..25399c89 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -72,6 +72,15 @@ Example: This is a server-only command. It directly reads from user.db as defined in the server config file server.yml. The command only works if 'auth-file' is properly defined.`, }, + { + Name: "generate", + Usage: "Generates a random token", + Action: execTokenGenerate, + Description: `Randomly generate a token to be used in provisioned tokens. + +This command only generates the token value, but does not persist it anywhere. +The output can be used in the 'auth-tokens' config option.`, + }, }, Description: `Manage access tokens for individual users. @@ -112,12 +121,12 @@ func execTokenAdd(c *cli.Context) error { return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err } - token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified()) + token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false) if err != nil { return err } @@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error { return err } u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error { var users []*user.User if username != "" { u, err := manager.User(username) - if err == user.ErrUserNotFound { + if errors.Is(err, user.ErrUserNotFound) { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err @@ -191,7 +200,7 @@ func execTokenList(c *cli.Context) error { usersWithTokens++ fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name) for _, t := range tokens { - var label, expires string + var label, expires, provisioned string if t.Label != "" { label = fmt.Sprintf(" (%s)", t.Label) } @@ -200,7 +209,10 @@ func execTokenList(c *cli.Context) error { } else { expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822)) } - fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822)) + if t.Provisioned { + provisioned = " (server config)" + } + fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned) } } if usersWithTokens == 0 { @@ -208,3 +220,8 @@ func execTokenList(c *cli.Context) error { } return nil } + +func execTokenGenerate(c *cli.Context) error { + fmt.Println(user.GenerateToken()) + return nil +} diff --git a/server/config.go b/server/config.go index 99d829b2..6a7c4cee 100644 --- a/server/config.go +++ b/server/config.go @@ -97,6 +97,7 @@ type Config struct { AuthDefault user.Permission AuthUsers []*user.User AuthAccess map[string][]*user.Grant + AuthTokens map[string][]*user.Token AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string diff --git a/server/server.go b/server/server.go index 55fa3af7..05b5b63a 100644 --- a/server/server.go +++ b/server/server.go @@ -203,6 +203,7 @@ func New(conf *Config) (*Server, error) { ProvisionEnabled: true, // Enable provisioning of users and access Users: conf.AuthUsers, Access: conf.AuthAccess, + Tokens: conf.AuthTokens, BcryptCost: conf.AuthBcryptCost, QueueWriterInterval: conf.AuthStatsQueueWriterInterval, } diff --git a/server/server.yml b/server/server.yml index 0d748640..2a623bc4 100644 --- a/server/server.yml +++ b/server/server.yml @@ -86,6 +86,8 @@ # Each entry is in the format "::", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user" # - auth-access is a list of access control entries that are automatically created when the server starts. # Each entry is in the format "::", e.g. "phil:mytopic:rw" or "phil:phil-*:rw". +# - auth-tokens is a list of access tokens that are automatically created when the server starts. +# Each entry is in the format ":[: