From 46a5338a3076fa139397848b6dfe42c4a5825e23 Mon Sep 17 00:00:00 2001 From: kap1bala Date: Mon, 18 May 2026 05:51:09 +0200 Subject: [PATCH 01/46] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/ --- web/public/static/langs/zh_Hans.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index fbbc1a2f..d66a8142 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -115,8 +115,8 @@ "subscribe_dialog_error_user_anonymous": "匿名", "prefs_notifications_title": "通知", "prefs_notifications_sound_title": "通知提示音", - "prefs_notifications_sound_description_none": "收到通知时不播放任何声音", - "prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 声音", + "prefs_notifications_sound_description_none": "收到通知时不播放任何提示音", + "prefs_notifications_sound_description_some": "收到通知时播放 {{sound}} 提示音", "prefs_notifications_sound_no_sound": "静音", "prefs_notifications_sound_play": "播放选中声音", "prefs_notifications_min_priority_title": "最低优先级", @@ -152,9 +152,9 @@ "prefs_appearance_title": "外观", "prefs_appearance_language_title": "语言", "prefs_appearance_theme_title": "主題", - "prefs_appearance_theme_system": "系統 (預設)", - "prefs_appearance_theme_dark": "黑暗模式", - "prefs_appearance_theme_light": "光亮模式", + "prefs_appearance_theme_system": "系统 (默认)", + "prefs_appearance_theme_dark": "深色模式", + "prefs_appearance_theme_light": "浅色模式", "priority_min": "最低", "priority_low": "低", "priority_default": "默认", From dbcc89ed8b431bc43042dbb3e07e000ab7eff9a5 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Wed, 27 May 2026 16:42:26 +0200 Subject: [PATCH 02/46] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/ --- web/public/static/langs/zh_Hant.json | 414 +++++++++++++-------------- 1 file changed, 207 insertions(+), 207 deletions(-) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index a340e2c7..07a92411 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -1,34 +1,34 @@ { - "account_basics_password_description": "更改你的帳戶密碼", - "account_basics_password_dialog_button_submit": "更改密碼", + "account_basics_password_description": "變更帳戶密碼", + "account_basics_password_dialog_button_submit": "變更密碼", "account_basics_password_dialog_confirm_password_label": "確認密碼", "account_basics_password_dialog_current_password_incorrect": "密碼錯誤", - "account_basics_password_dialog_current_password_label": "當前密碼", + "account_basics_password_dialog_current_password_label": "目前密碼", "account_basics_password_dialog_new_password_label": "新密碼", - "account_basics_password_dialog_title": "更改密碼", + "account_basics_password_dialog_title": "變更密碼", "account_basics_password_title": "密碼", - "account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼板", + "account_basics_phone_numbers_copied_to_clipboard": "電話號碼已複製到剪貼簿", "account_basics_phone_numbers_description": "電話通知", "account_basics_phone_numbers_dialog_channel_call": "撥打", - "account_basics_phone_numbers_dialog_channel_sms": "短信", + "account_basics_phone_numbers_dialog_channel_sms": "簡訊", "account_basics_phone_numbers_dialog_check_verification_button": "確認碼", "account_basics_phone_numbers_dialog_code_label": "驗證碼", "account_basics_phone_numbers_dialog_code_placeholder": "例如:123456", - "account_basics_phone_numbers_dialog_description": "要使用來電通知功能,你需要新增並驗證至少一個電話號碼。可以通過短信或電話驗證。", + "account_basics_phone_numbers_dialog_description": "若要使用來電通知功能,請先新增並驗證至少一個電話號碼。你可以透過簡訊或電話完成驗證。", "account_basics_phone_numbers_dialog_number_label": "電話號碼", "account_basics_phone_numbers_dialog_number_placeholder": "例如:+1222333444", "account_basics_phone_numbers_dialog_title": "新增電話號碼", "account_basics_phone_numbers_dialog_verify_button_call": "撥打電話", - "account_basics_phone_numbers_dialog_verify_button_sms": "發送資訊", - "account_basics_phone_numbers_no_phone_numbers_yet": "無可執行的電話號碼", + "account_basics_phone_numbers_dialog_verify_button_sms": "傳送簡訊", + "account_basics_phone_numbers_no_phone_numbers_yet": "尚無電話號碼", "account_basics_phone_numbers_title": "電話號碼", "account_basics_tier_admin_suffix_no_tier": "(無等級)", "account_basics_tier_admin_suffix_with_tier": "(有 {{tier}} 等級)", "account_basics_tier_admin": "管理員", "account_basics_tier_basic": "基礎版", "account_basics_tier_canceled_subscription": "你的訂閱已取消,並將在 {{date}} 降級為免費帳戶。", - "account_basics_tier_change_button": "改變", - "account_basics_tier_description": "你帳戶的權限級別", + "account_basics_tier_change_button": "變更", + "account_basics_tier_description": "你的帳戶權限等級", "account_basics_tier_free": "免費", "account_basics_tier_interval_monthly": "每月", "account_basics_tier_interval_yearly": "每年", @@ -40,149 +40,149 @@ "account_basics_title": "帳戶", "account_basics_username_admin_tooltip": "你是管理員", "account_basics_username_description": "嘿,那是你 ❤", - "account_basics_username_title": "用戶名", + "account_basics_username_title": "使用者名稱", "account_delete_description": "永久刪除你的帳戶", - "account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再訪問計費儀錶板。", + "account_delete_dialog_billing_warning": "刪除你的帳戶也會立即取消你的計費訂閱。你將無法再存取計費儀錶板。", "account_delete_dialog_button_cancel": "取消", "account_delete_dialog_button_submit": "永久刪除帳戶", - "account_delete_dialog_description": "這將永久刪除你的帳戶,包括存儲在伺服器上的所有數據。刪除後,你的用戶名將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。", + "account_delete_dialog_description": "這將永久刪除你的帳戶,包括儲存在伺服器上的所有資料。刪除後,你的使用者名稱將在 7 天內不可用。如果你真的想繼續,請在下面的框中使用你的密碼作確認。", "account_delete_dialog_label": "密碼", "account_delete_title": "刪除帳戶", - "account_tokens_delete_dialog_description": "在刪除訪問令牌之前,請確保沒有應用程序或腳本正在活躍使用它。 此操作無法撤銷。", - "account_tokens_delete_dialog_submit_button": "永久删除令牌", - "account_tokens_delete_dialog_title": "刪除訪問令牌", - "account_tokens_description": "通過 ntfy API 發布和訂閱時使用訪問令牌,因此你不必發送你的帳戶憑證。查看文檔以了解更多資訊。", + "account_tokens_delete_dialog_description": "在刪除存取權杖之前,請確認沒有應用程式或指令碼正在使用此權杖。此操作無法復原。", + "account_tokens_delete_dialog_submit_button": "永久刪除權杖", + "account_tokens_delete_dialog_title": "刪除存取權杖", + "account_tokens_description": "透過 ntfy API 發布和訂閱時,請使用存取權杖,這樣就不必傳送你的帳戶憑證。請參閱說明以瞭解更多資訊。", "account_tokens_dialog_button_cancel": "取消", - "account_tokens_dialog_button_create": "創建令牌", - "account_tokens_dialog_button_update": "更新令牌", - "account_tokens_dialog_expires_label": "訪問令牌過期於", - "account_tokens_dialog_expires_never": "令牌永不過期", + "account_tokens_dialog_button_create": "建立權杖", + "account_tokens_dialog_button_update": "更新權杖", + "account_tokens_dialog_expires_label": "存取權杖到期時間", + "account_tokens_dialog_expires_never": "權杖永不過期", "account_tokens_dialog_expires_unchanged": "保持過期日期不變", - "account_tokens_dialog_expires_x_days": "令牌在 {{days}} 天後過期", - "account_tokens_dialog_expires_x_hours": "令牌在 {{hours}} 小時後過期", + "account_tokens_dialog_expires_x_days": "權杖將在 {{days}} 天後到期", + "account_tokens_dialog_expires_x_hours": "權杖將在 {{hours}} 小時後到期", "account_tokens_dialog_label": "標籤,例如:Radarr 通知", - "account_tokens_dialog_title_create": "創建訪問令牌", - "account_tokens_dialog_title_delete": "刪除訪問令牌", - "account_tokens_dialog_title_edit": "編輯訪問令牌", - "account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除當前會話令牌", - "account_tokens_table_copied_to_clipboard": "已複製訪問令牌", - "account_tokens_table_create_token_button": "創建訪問令牌", - "account_tokens_table_current_session": "當前瀏覽器會話", + "account_tokens_dialog_title_create": "建立存取權杖", + "account_tokens_dialog_title_delete": "刪除存取權杖", + "account_tokens_dialog_title_edit": "編輯存取權杖", + "account_tokens_table_cannot_delete_or_edit": "無法編輯或刪除目前的工作階段權杖", + "account_tokens_table_copied_to_clipboard": "已複製存取權杖", + "account_tokens_table_create_token_button": "建立存取權杖", + "account_tokens_table_current_session": "目前的瀏覽器工作階段", "account_tokens_table_expires_header": "過期", "account_tokens_table_label_header": "標籤", - "account_tokens_table_last_access_header": "最後訪問", - "account_tokens_table_last_origin_tooltip": "於IP地址 {{ip}},點擊查找", + "account_tokens_table_last_access_header": "最後存取", + "account_tokens_table_last_origin_tooltip": "來自 IP 位址 {{ip}},點選即可查詢", "account_tokens_table_never_expires": "永不過期", - "account_tokens_table_token_header": "令牌", - "account_tokens_title": "訪問令牌", - "account_upgrade_dialog_billing_contact_email": "有關賬單問題,請直接聯繫我們 。", - "account_upgrade_dialog_billing_contact_website": "有關賬單問題,請參考我們的網站 。", + "account_tokens_table_token_header": "權杖", + "account_tokens_title": "存取權杖", + "account_upgrade_dialog_billing_contact_email": "如有帳單問題,請直接聯絡我們。", + "account_upgrade_dialog_billing_contact_website": "如有帳單問題,請參考我們的網站。", "account_upgrade_dialog_button_cancel_subscription": "取消訂閱", "account_upgrade_dialog_button_cancel": "取消", "account_upgrade_dialog_button_pay_now": "立即付款並訂閱", "account_upgrade_dialog_button_redirect_signup": "立即註冊", "account_upgrade_dialog_button_update_subscription": "更新訂閱", - "account_upgrade_dialog_cancel_warning": "這將取消你的訂閱,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及緩存在伺服器上的訊息將被刪除。", + "account_upgrade_dialog_cancel_warning": "這將取消你的訂閱,並在 {{date}} 降級你的帳戶。在那一天,主題保留以及快取在伺服器上的訊息將被刪除。", "account_upgrade_dialog_interval_monthly": "每月", "account_upgrade_dialog_interval_yearly_discount_save_up_to": "節省高達 {{discount}}%", "account_upgrade_dialog_interval_yearly_discount_save": "節省 {{discount}}%", "account_upgrade_dialog_interval_yearly": "每年", - "account_upgrade_dialog_proration_info": "按比例分配:在付費計劃之間升級時,差價將被立刻收取。在降級到較低級別時,餘額將被用於支付未來的賬單周期。", - "account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 1 項保留。你可以在設置中刪除保留。", - "account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於當前等級。在更改你的等級之前,請至少刪除 {{count}} 項保留。你可以在設置中刪除保留。", - "account_upgrade_dialog_tier_current_label": "當前", - "account_upgrade_dialog_tier_features_attachment_file_size": "每個文件 {{filesize}}", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總存儲空間", - "account_upgrade_dialog_tier_features_calls_one": "每日一通電話", - "account_upgrade_dialog_tier_features_calls_other": "每日{{calls}} 通電話", - "account_upgrade_dialog_tier_features_emails_one": "每日一封郵件", - "account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 條郵件", - "account_upgrade_dialog_tier_features_messages_one": "每日一條訊息", - "account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 條訊息", + "account_upgrade_dialog_proration_info": "按比例計費:升級到其他付費方案時,系統會立即收取差價。降級到較低等級時,餘額會用於支付未來的帳單週期。", + "account_upgrade_dialog_reservations_warning_one": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,請至少刪除 1 項保留。你可以在設定中移除保留。", + "account_upgrade_dialog_reservations_warning_other": "所選等級允許的保留主題少於目前等級。在變更你的等級之前,請至少刪除 {{count}} 項保留。你可以在設定中移除保留。", + "account_upgrade_dialog_tier_current_label": "目前", + "account_upgrade_dialog_tier_features_attachment_file_size": "每個檔案 {{filesize}}", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} 總儲存空間", + "account_upgrade_dialog_tier_features_calls_one": "每日 {{calls}} 通電話", + "account_upgrade_dialog_tier_features_calls_other": "每日 {{calls}} 通電話", + "account_upgrade_dialog_tier_features_emails_one": "每日 {{emails}} 封電子郵件", + "account_upgrade_dialog_tier_features_emails_other": "每日 {{emails}} 封電子郵件", + "account_upgrade_dialog_tier_features_messages_one": "每日 {{messages}} 則訊息", + "account_upgrade_dialog_tier_features_messages_other": "每日 {{messages}} 則訊息", "account_upgrade_dialog_tier_features_no_calls": "沒有電話", "account_upgrade_dialog_tier_features_no_reservations": "無保留主題", - "account_upgrade_dialog_tier_features_reservations_one": "保留一條主題", - "account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 條主題", + "account_upgrade_dialog_tier_features_reservations_one": "保留 {{reservations}} 個主題", + "account_upgrade_dialog_tier_features_reservations_other": "保留 {{reservations}} 個主題", "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月計費。", - "account_upgrade_dialog_tier_price_billed_yearly": "{{價格}} 按年計費。節省 {{save}}。", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} 按年計費。節省 {{save}}。", "account_upgrade_dialog_tier_price_per_month": "月", "account_upgrade_dialog_tier_selected_label": "已選", - "account_upgrade_dialog_title": "更改帳戶等級", - "account_usage_attachment_storage_description": "每個文件 {{filesize}},在 {{expiry}} 後刪除", - "account_usage_attachment_storage_title": "附件存儲", - "account_usage_basis_ip_description": "此帳戶的使用統計資訊和限制基於你的 IP 地址,因此可能會與其他用戶共享。上面顯示的限制是基於現有速率限制的近似值。", + "account_upgrade_dialog_title": "變更帳戶等級", + "account_usage_attachment_storage_description": "每個檔案 {{filesize}},在 {{expiry}} 後刪除", + "account_usage_attachment_storage_title": "附件儲存", + "account_usage_basis_ip_description": "此帳戶會依你的 IP 位址計算使用統計與限制,因此可能會與其他使用者共用。上方限制是根據現有速率限制估算的近似值。", "account_usage_calls_none": "此帳號無法撥打電話", "account_usage_calls_title": "已撥打電話", - "account_usage_cannot_create_portal_session": "無法打開計費門戶", - "account_usage_emails_title": "已發送電子郵件", - "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重置", + "account_usage_cannot_create_portal_session": "無法開啟計費入口網站", + "account_usage_emails_title": "已傳送電子郵件", + "account_usage_limits_reset_daily": "使用限制每天午夜 (UTC) 重設", "account_usage_messages_title": "已發布訊息", - "account_usage_of_limit": "{{limit}} 的", + "account_usage_of_limit": "/ {{limit}}", "account_usage_reservations_none": "此帳戶沒有保留主題", "account_usage_reservations_title": "保留主題", "account_usage_title": "使用量", "account_usage_unlimited": "無限", "action_bar_account": "帳戶", - "action_bar_change_display_name": "更改顯示名稱", + "action_bar_change_display_name": "變更顯示名稱", "action_bar_clear_notifications": "清除所有通知", - "action_bar_logo_alt": "ntfy 標識", + "action_bar_logo_alt": "ntfy 標誌", "action_bar_mute_notifications": "靜音", "action_bar_profile_logout": "登出", "action_bar_profile_settings": "設定", "action_bar_profile_title": "個人資料", "action_bar_reservation_add": "保留主題", "action_bar_reservation_delete": "移除保留", - "action_bar_reservation_edit": "更改保留", + "action_bar_reservation_edit": "變更保留", "action_bar_reservation_limit_reached": "達到限制", - "action_bar_send_test_notification": "發送測試通知", + "action_bar_send_test_notification": "傳送測試通知", "action_bar_settings": "設定", "action_bar_show_menu": "顯示選單", - "action_bar_sign_in": "登錄", + "action_bar_sign_in": "登入", "action_bar_sign_up": "註冊", "action_bar_toggle_action_menu": "開啟或關閉操作選單", "action_bar_toggle_mute": "通知靜音/解除通知靜音", "action_bar_unmute_notifications": "取消靜音", "action_bar_unsubscribe": "取消訂閱", - "alert_notification_ios_install_required_description": "要接收通知,請在 iOS 上點擊共享,然後添加到主屏幕", + "alert_notification_ios_install_required_description": "若要接收通知,請在 iOS 上點選 [分享],然後選擇 [加入主畫面]", "alert_notification_ios_install_required_title": "需要安裝 iOS 應用程式", - "alert_notification_permission_denied_description": "你已禁用通知。要重新啟用通知,請在瀏覽器設置中啟用通知", - "alert_notification_permission_denied_title": "已禁用通知", + "alert_notification_permission_denied_description": "你已停用通知。若要重新啟用通知,請在瀏覽器設定中開啟通知", + "alert_notification_permission_denied_title": "已停用通知", "alert_notification_permission_required_button": "現在授予", - "alert_notification_permission_required_description": "授予瀏覽器顯示桌面通知的權限", - "alert_notification_permission_required_title": "已禁用通知", + "alert_notification_permission_required_description": "請授予瀏覽器顯示桌面通知的權限", + "alert_notification_permission_required_title": "已停用通知", "alert_not_supported_context_description": "通知僅支援 HTTPS。這是 Notifications API 的限制。", "alert_not_supported_description": "你的瀏覽器不支援通知", "alert_not_supported_title": "不支援通知", "common_add": "新增", - "common_back": "返回", + "common_back": "上一頁", "common_cancel": "取消", - "common_copy_to_clipboard": "複製到剪貼板", - "common_save": "保存", - "display_name_dialog_description": "為訂閱列表中顯示的主題設置一個替代名稱。這有助於更輕鬆地識別名稱複雜的主題。", + "common_copy_to_clipboard": "複製到剪貼簿", + "common_save": "儲存", + "display_name_dialog_description": "為訂閱清單中顯示的主題設定替代名稱。這有助於更輕鬆地識別名稱複雜的主題。", "display_name_dialog_placeholder": "顯示名稱", - "display_name_dialog_title": "更改顯示名稱", - "emoji_picker_search_clear": "清除搜索", - "emoji_picker_search_placeholder": "查找表情符號", - "error_boundary_button_copy_stack_trace": "複製堆疊追踪", - "error_boundary_button_reload_ntfy": "重新加載 ntfy", - "error_boundary_description": "這顯然不應該發生。對此非常抱歉。
如果你有時間,請在GitHub上報告,或通過Discord或Matrix告訴我們。", + "display_name_dialog_title": "變更顯示名稱", + "emoji_picker_search_clear": "清除搜尋", + "emoji_picker_search_placeholder": "搜尋表情符號", + "error_boundary_button_copy_stack_trace": "複製堆疊追蹤", + "error_boundary_button_reload_ntfy": "重新載入 ntfy", + "error_boundary_description": "這顯然不應該發生。對此非常抱歉。
如果你有時間,請在GitHub回報,或透過Discord或Matrix聯絡我們。", "error_boundary_gathering_info": "收集更多資訊……", - "error_boundary_stack_trace": "堆疊追踪", + "error_boundary_stack_trace": "堆疊追蹤", "error_boundary_title": "天啊,ntfy 崩潰了", - "error_boundary_unsupported_indexeddb_description": "Ntfy Web應用程式需要IndexedDB才能運行,且你的瀏覽器在隱私瀏覽模式下不支援IndexedDB。

儘管這很不幸,但在隱私瀏覽模式下使用ntfy Web應用程式也沒有多大意義,因為所有東西都存儲在瀏覽器存儲中。你可以在本GitHub問題中閱讀有關它的更多資訊,或者在Discord或Matrix上與我們交談。", + "error_boundary_unsupported_indexeddb_description": "ntfy Web 應用程式需要 IndexedDB 才能運作,而你的瀏覽器在隱私瀏覽模式下不支援 IndexedDB。

很遺憾,不過在隱私瀏覽模式下使用 ntfy Web 應用程式其實也不太合理,因為所有資料都儲存在瀏覽器儲存空間中。你可以在這個 GitHub issue中閱讀更多資訊,或透過Discord或Matrix與我們討論。", "error_boundary_unsupported_indexeddb_title": "不支援隱私瀏覽", - "login_disabled": "登錄已禁用", - "login_form_button_submit": "登錄", + "login_disabled": "登入已停用", + "login_form_button_submit": "登入", "login_link_signup": "註冊", - "login_title": "請登錄你的 ntfy 帳戶", - "message_bar_error_publishing": "發佈通知時出錯", + "login_title": "請登入你的 ntfy 帳戶", + "message_bar_error_publishing": "發布通知時發生錯誤", "message_bar_publish": "發布訊息", - "message_bar_show_dialog": "顯示發布對話框", + "message_bar_show_dialog": "顯示發布對話方塊", "message_bar_type_message": "在此處輸入訊息", "nav_button_account": "帳戶", "nav_button_all_notifications": "全部通知", - "nav_button_connecting": "正在連接", - "nav_button_documentation": "文檔", + "nav_button_connecting": "正在連線", + "nav_button_documentation": "說明", "nav_button_muted": "已暫停通知", "nav_button_publish_message": "發布通知", "nav_button_settings": "設定", @@ -191,44 +191,44 @@ "nav_upgrade_banner_description": "保留主題,更多訊息和郵件,以及更大的附件", "nav_upgrade_banner_label": "升級到 ntfy Pro", "notifications_actions_failed_notification": "通知失敗", - "notifications_actions_http_request_title": "發送 HTTP {{method}} 到 {{url}}", - "notifications_actions_not_supported": "網頁應用程序不支援此操作", + "notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}", + "notifications_actions_not_supported": "網頁應用程式不支援此操作", "notifications_actions_open_url_title": "轉到 {{url}}", - "notifications_attachment_copy_url_button": "複製連結地址", - "notifications_attachment_copy_url_title": "將附件中連結地址複製到剪貼板", - "notifications_attachment_file_app": "安卓應用程式", - "notifications_attachment_file_audio": "聲音文件", - "notifications_attachment_file_document": "其他文件", - "notifications_attachment_file_image": "圖片文件", - "notifications_attachment_file_video": "影片文件", + "notifications_attachment_copy_url_button": "複製 URL", + "notifications_attachment_copy_url_title": "將附件 URL 複製到剪貼簿", + "notifications_attachment_file_app": "Android 應用程式檔案", + "notifications_attachment_file_audio": "音訊檔案", + "notifications_attachment_file_document": "其他檔案", + "notifications_attachment_file_image": "圖片檔案", + "notifications_attachment_file_video": "影片檔案", "notifications_attachment_image": "附件圖片", "notifications_attachment_link_expired": "下載連結已過期", "notifications_attachment_link_expires": "連結在 {{date}} 過期", - "notifications_attachment_open_button": "打開附件", + "notifications_attachment_open_button": "開啟附件", "notifications_attachment_open_title": "轉到 {{url}}", - "notifications_click_copy_url_button": "複製鏈結", - "notifications_click_copy_url_title": "複製鏈結地址到剪貼板", - "notifications_click_open_button": "打開鏈結", - "notifications_copied_to_clipboard": "複製到剪貼板", + "notifications_click_copy_url_button": "複製連結", + "notifications_click_copy_url_title": "將連結 URL 複製到剪貼簿", + "notifications_click_open_button": "開啟連結", + "notifications_copied_to_clipboard": "複製到剪貼簿", "notifications_delete": "刪除", - "notifications_example": "示例", + "notifications_example": "範例", "notifications_list_item": "通知", - "notifications_list": "通知列表", - "notifications_loading": "正在加載通知……", + "notifications_list": "通知清單", + "notifications_loading": "正在載入通知……", "notifications_mark_read": "標記為已讀", - "notifications_more_details": "有關更多資訊,請查看網站或文檔。", + "notifications_more_details": "如需更多資訊,請參閱網站或說明。", "notifications_new_indicator": "新通知", - "notifications_none_for_any_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題鏈結即可。以下是使用你的主題的示例。", + "notifications_none_for_any_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。以下是使用你的主題的範例。", "notifications_none_for_any_title": "你尚未收到任何通知。", - "notifications_none_for_topic_description": "要向此主題發送通知,只需使用 PUT 或 POST 到主題連結即可。", + "notifications_none_for_topic_description": "若要向此主題傳送通知,只要將 PUT 或 POST 請求傳送到主題 URL 即可。", "notifications_none_for_topic_title": "你尚未收到有關此主題的任何通知。", - "notifications_no_subscriptions_description": "點擊 \"{{linktext}}\" 連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 發送訊息,你將在這裡收到通知。", - "notifications_no_subscriptions_title": "看起來你還未有任何訂閱。", - "notifications_priority_x": "優先級 {{priority}}", + "notifications_no_subscriptions_description": "點選「{{linktext}}」連結以建立或訂閱主題。之後,你可以使用 PUT 或 POST 傳送訊息,並在這裡收到通知。", + "notifications_no_subscriptions_title": "看起來你還沒有任何訂閱。", + "notifications_priority_x": "優先順序 {{priority}}", "notifications_tags": "標記", "prefs_appearance_language_title": "語言", - "prefs_appearance_theme_dark": "黑暗模式", - "prefs_appearance_theme_light": "光亮模式", + "prefs_appearance_theme_dark": "深色模式", + "prefs_appearance_theme_light": "淺色模式", "prefs_appearance_theme_system": "系統 (預設)", "prefs_appearance_theme_title": "主題", "prefs_appearance_title": "外觀", @@ -238,171 +238,171 @@ "prefs_notifications_delete_after_one_day": "一天後", "prefs_notifications_delete_after_one_month_description": "一個月後自動刪除通知", "prefs_notifications_delete_after_one_month": "一個月後", - "prefs_notifications_delete_after_one_week_description": "一周後自動刪除通知", - "prefs_notifications_delete_after_one_week": "一周後", + "prefs_notifications_delete_after_one_week_description": "一週後自動刪除通知", + "prefs_notifications_delete_after_one_week": "一週後", "prefs_notifications_delete_after_three_hours_description": "三小時後自動刪除通知", "prefs_notifications_delete_after_three_hours": "三小時後", "prefs_notifications_delete_after_title": "刪除通知", - "prefs_notifications_min_priority_any": "任意優先級", - "prefs_notifications_min_priority_default_and_higher": "默認優先級或更高", - "prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先級如何", - "prefs_notifications_min_priority_description_max": "僅顯示最高優先級的通知", - "prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先級為{{number}}({{name}})或以上的通知", - "prefs_notifications_min_priority_high_and_higher": "高優先級或更高", - "prefs_notifications_min_priority_low_and_higher": "低優先級或更高", - "prefs_notifications_min_priority_max_only": "僅最高優先級", - "prefs_notifications_min_priority_title": "最低優先級", + "prefs_notifications_min_priority_any": "任何優先順序", + "prefs_notifications_min_priority_default_and_higher": "預設優先順序或更高", + "prefs_notifications_min_priority_description_any": "顯示所有通知,無論優先順序如何", + "prefs_notifications_min_priority_description_max": "僅顯示最高優先順序的通知", + "prefs_notifications_min_priority_description_x_or_higher": "僅顯示優先順序為 {{number}}({{name}})或以上的通知", + "prefs_notifications_min_priority_high_and_higher": "高優先順序或更高", + "prefs_notifications_min_priority_low_and_higher": "低優先順序或更高", + "prefs_notifications_min_priority_max_only": "僅最高優先順序", + "prefs_notifications_min_priority_title": "最低優先順序", "prefs_notifications_sound_description_none": "收到通知時不播放任何聲音", "prefs_notifications_sound_description_some": "收到通知時播放 {{sound}} 聲音", "prefs_notifications_sound_no_sound": "靜音", - "prefs_notifications_sound_play": "播放選中聲音", + "prefs_notifications_sound_play": "播放選取的聲音", "prefs_notifications_sound_title": "通知提示音", "prefs_notifications_title": "通知", - "prefs_notifications_web_push_disabled_description": "當網頁程式在運行時將會收到通知 (透過 WebSocket)", - "prefs_notifications_web_push_disabled": "己暫用", - "prefs_notifications_web_push_enabled_description": "即使網頁程式未有運街亦會收到通知 (via Web Push)", - "prefs_notifications_web_push_enabled": "己為 {{server}} 啟用", + "prefs_notifications_web_push_disabled_description": "網頁應用程式執行時會收到通知(透過 WebSocket)", + "prefs_notifications_web_push_disabled": "已停用", + "prefs_notifications_web_push_enabled_description": "即使網頁應用程式未執行,也會收到通知(透過 Web Push)", + "prefs_notifications_web_push_enabled": "已為 {{server}} 啟用", "prefs_notifications_web_push_title": "背景通知", "prefs_reservations_add_button": "新增保留主題", - "prefs_reservations_delete_button": "重置主題訪問", - "prefs_reservations_description": "你可以在此處保留主題名稱供個人使用。保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", - "prefs_reservations_dialog_access_label": "訪問", - "prefs_reservations_dialog_description": "保留主題使你擁有該主題的所有權,並允許你為其他用戶定義對該主題的訪問權限。", + "prefs_reservations_delete_button": "重設主題存取權限", + "prefs_reservations_description": "你可以在此保留主題名稱供個人使用。保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。", + "prefs_reservations_dialog_access_label": "存取權限", + "prefs_reservations_dialog_description": "保留主題代表你擁有該主題的所有權,並可為其他使用者定義該主題的存取權限。", "prefs_reservations_dialog_title_add": "保留主題", "prefs_reservations_dialog_title_delete": "刪除主題保留", "prefs_reservations_dialog_title_edit": "編輯保留主題", "prefs_reservations_dialog_topic_label": "主題", - "prefs_reservations_edit_button": "編輯主題訪問", + "prefs_reservations_edit_button": "編輯主題存取權限", "prefs_reservations_limit_reached": "你已達到保留主題限制。", - "prefs_reservations_table_access_header": "訪問", - "prefs_reservations_table_click_to_subscribe": "點擊以訂閱", - "prefs_reservations_table_everyone_deny_all": "只有我可以發佈和訂閱", - "prefs_reservations_table_everyone_read_only": "我可以發佈和訂閱,每個人都可以訂閱", - "prefs_reservations_table_everyone_read_write": "每個人都可以發佈和訂閱", - "prefs_reservations_table_everyone_write_only": "我可以發佈和訂閱,每個人都可以發佈", + "prefs_reservations_table_access_header": "存取權限", + "prefs_reservations_table_click_to_subscribe": "點選以訂閱", + "prefs_reservations_table_everyone_deny_all": "只有我可以發布和訂閱", + "prefs_reservations_table_everyone_read_only": "我可以發布和訂閱,每個人都可以訂閱", + "prefs_reservations_table_everyone_read_write": "每個人都可以發布和訂閱", + "prefs_reservations_table_everyone_write_only": "我可以發布和訂閱,每個人都可以發布", "prefs_reservations_table_not_subscribed": "未訂閱", "prefs_reservations_table_topic_header": "主題", "prefs_reservations_table": "保留主題表格", "prefs_reservations_title": "保留主題", "prefs_users_add_button": "新增使用者", - "prefs_users_delete_button": "刪除用戶", - "prefs_users_description_no_sync": "用戶和密碼不會同步到你的賬戶。", - "prefs_users_description": "在此處新增/刪除受保護主題的使用者。請注意,使用者名和密碼將存儲在瀏覽器的本地存儲中。", - "prefs_users_dialog_base_url_label": "服務連結地址,例如 https://ntfy.sh", + "prefs_users_delete_button": "刪除使用者", + "prefs_users_description_no_sync": "使用者和密碼不會同步到你的帳戶。", + "prefs_users_description": "在此新增或刪除受保護主題的使用者。請注意,使用者名稱和密碼會儲存在瀏覽器的本機儲存空間中。", + "prefs_users_dialog_base_url_label": "服務 URL,例如 https://ntfy.sh", "prefs_users_dialog_password_label": "密碼", "prefs_users_dialog_title_add": "新增使用者", "prefs_users_dialog_title_edit": "編輯使用者", - "prefs_users_dialog_username_label": "使用者名,例如 phil", - "prefs_users_edit_button": "編輯用戶", - "prefs_users_table_base_url_header": "服務連結地址", - "prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登錄用戶", - "prefs_users_table_user_header": "用戶", - "prefs_users_table": "用戶表", + "prefs_users_dialog_username_label": "使用者名稱,例如 phil", + "prefs_users_edit_button": "編輯使用者", + "prefs_users_table_base_url_header": "服務 URL", + "prefs_users_table_cannot_delete_or_edit": "無法刪除或編輯已登入使用者", + "prefs_users_table_user_header": "使用者", + "prefs_users_table": "使用者表格", "prefs_users_title": "管理使用者", "priority_default": "預設", "priority_high": "高", "priority_low": "低", "priority_max": "最高", "priority_min": "最低", - "publish_dialog_attached_file_filename_placeholder": "附件文件名", - "publish_dialog_attached_file_remove": "刪除附件文件", - "publish_dialog_attached_file_title": "附件文件:", - "publish_dialog_attach_label": "附件連結地址", - "publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 文件限制和配額,剩餘 {{remainingBytes}}", - "publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 文件限制", + "publish_dialog_attached_file_filename_placeholder": "附件檔名", + "publish_dialog_attached_file_remove": "刪除附件檔案", + "publish_dialog_attached_file_title": "附件檔案:", + "publish_dialog_attach_label": "附件 URL", + "publish_dialog_attachment_limits_file_and_quota_reached": "超過 {{fileSizeLimit}} 的檔案限制和配額,剩餘 {{remainingBytes}}", + "publish_dialog_attachment_limits_file_reached": "超過 {{fileSizeLimit}} 的檔案限制", "publish_dialog_attachment_limits_quota_reached": "超過配額,剩餘 {{remainingBytes}}", - "publish_dialog_attach_placeholder": "使用鏈結地址附加文件,例如 https://f-droid.org/F-Droid.apk", - "publish_dialog_attach_reset": "移除附件鏈結地址", - "publish_dialog_base_url_label": "服務鏈結地址", - "publish_dialog_base_url_placeholder": "服務鏈結地址,例如 https://example.com", - "publish_dialog_button_cancel_sending": "取消發送", + "publish_dialog_attach_placeholder": "透過 URL 附加檔案,例如 https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "移除附件 URL", + "publish_dialog_base_url_label": "服務 URL", + "publish_dialog_base_url_placeholder": "服務 URL,例如 https://example.com", + "publish_dialog_button_cancel_sending": "取消傳送", "publish_dialog_button_cancel": "取消", - "publish_dialog_button_send": "發送", + "publish_dialog_button_send": "傳送", "publish_dialog_call_item": "撥打電話 {{number}}", "publish_dialog_call_label": "撥號", "publish_dialog_call_reset": "清空撥號", "publish_dialog_checkbox_markdown": "格式化為 Markdown", "publish_dialog_checkbox_publish_another": "發布另一個", - "publish_dialog_chip_attach_file_label": "本地文件附件", - "publish_dialog_chip_attach_url_label": "鏈結附件地址", + "publish_dialog_chip_attach_file_label": "本機檔案附件", + "publish_dialog_chip_attach_url_label": "透過 URL 附加檔案", "publish_dialog_chip_call_label": "撥號", "publish_dialog_chip_call_no_verified_numbers_tooltip": "未驗證的電話號碼", - "publish_dialog_chip_click_label": "點擊鏈結地址", + "publish_dialog_chip_click_label": "點選 URL", "publish_dialog_chip_delay_label": "延期投遞", - "publish_dialog_chip_email_label": "轉發郵件", + "publish_dialog_chip_email_label": "轉寄電子郵件", "publish_dialog_chip_topic_label": "變更主題", - "publish_dialog_click_label": "點擊鏈結地址", - "publish_dialog_click_placeholder": "點擊通知時打開鏈結地址", - "publish_dialog_click_reset": "移除點擊連結地址", + "publish_dialog_click_label": "點選 URL", + "publish_dialog_click_placeholder": "點選通知時開啟的 URL", + "publish_dialog_click_reset": "移除點選 URL", "publish_dialog_delay_label": "延期", "publish_dialog_delay_placeholder": "延期投遞,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(僅限英語)", "publish_dialog_delay_reset": "刪除延期投遞", - "publish_dialog_details_examples_description": "有關所有發送功能的範例和詳細說明,請參閱文檔。", - "publish_dialog_drop_file_here": "將文件拖拽至此", + "publish_dialog_details_examples_description": "如需所有傳送功能的範例和詳細說明,請參閱說明。", + "publish_dialog_drop_file_here": "將檔案拖曳到這裡", "publish_dialog_email_label": "電子郵件", - "publish_dialog_email_placeholder": "將通知轉發到的地址,例如 phil@example.com", - "publish_dialog_email_reset": "移除電子郵件轉發", + "publish_dialog_email_placeholder": "要轉寄通知的電子郵件地址,例如 phil@example.com", + "publish_dialog_email_reset": "移除電子郵件轉寄", "publish_dialog_emoji_picker_show": "選擇表情符號", - "publish_dialog_filename_label": "文件名", - "publish_dialog_filename_placeholder": "附件文件名", + "publish_dialog_filename_label": "檔名", + "publish_dialog_filename_placeholder": "附件檔名", "publish_dialog_message_label": "訊息", "publish_dialog_message_placeholder": "在此輸入訊息", "publish_dialog_message_published": "已發布通知", - "publish_dialog_other_features": "其它功能:", - "publish_dialog_priority_default": "默認優先級", - "publish_dialog_priority_high": "高優先級", - "publish_dialog_priority_label": "優先級", - "publish_dialog_priority_low": "低優先級", - "publish_dialog_priority_max": "最高優先級", - "publish_dialog_priority_min": "最低優先級", + "publish_dialog_other_features": "其他功能:", + "publish_dialog_priority_default": "預設優先順序", + "publish_dialog_priority_high": "高優先順序", + "publish_dialog_priority_label": "優先順序", + "publish_dialog_priority_low": "低優先順序", + "publish_dialog_priority_max": "最高優先順序", + "publish_dialog_priority_min": "最低優先順序", "publish_dialog_progress_uploading_detail": "正在上傳 {{loaded}}/{{total}} ({{percent}}%) ……", "publish_dialog_progress_uploading": "正在上傳……", "publish_dialog_tags_label": "標記", - "publish_dialog_tags_placeholder": "英文逗號分隔標記列表,例如 warning, srv1-backup", + "publish_dialog_tags_placeholder": "以英文逗號分隔的標記清單,例如 warning, srv1-backup", "publish_dialog_title_label": "主題", "publish_dialog_title_no_topic": "發布通知", "publish_dialog_title_placeholder": "主題標題,例如:磁碟空間警告", "publish_dialog_title_topic": "發布到 {{topic}}", "publish_dialog_topic_label": "主題名稱", "publish_dialog_topic_placeholder": "主題名稱,例如 phil_alerts", - "publish_dialog_topic_reset": "重置主題", - "reservation_delete_dialog_action_delete_description": "緩存的郵件和附件將被永久刪除。此操作無法撤銷。", - "reservation_delete_dialog_action_delete_title": "刪除緩存的郵件和附件", - "reservation_delete_dialog_action_keep_description": "緩存在伺服器上的訊息和附件將對知道主題名稱的人公開可見。", - "reservation_delete_dialog_action_keep_title": "保留緩存的郵件和附件", - "reservation_delete_dialog_description": "刪除保留會放棄對該主題的所有權,並允許其他人保留它。你可以保留或刪除現有郵件和附件。", + "publish_dialog_topic_reset": "重設主題", + "reservation_delete_dialog_action_delete_description": "系統會永久刪除快取的訊息和附件。此操作無法復原。", + "reservation_delete_dialog_action_delete_title": "刪除快取的訊息和附件", + "reservation_delete_dialog_action_keep_description": "只要知道主題名稱,任何人都能看到伺服器快取的訊息和附件。", + "reservation_delete_dialog_action_keep_title": "保留快取的訊息和附件", + "reservation_delete_dialog_description": "刪除保留會放棄該主題的所有權,並允許其他人保留。你可以保留或刪除現有訊息和附件。", "reservation_delete_dialog_submit_button": "刪除保留", - "reserve_dialog_checkbox_label": "保留主題並配置訪問", - "signup_already_have_account": "已有帳戶?登錄!", - "signup_disabled": "註冊已禁用", - "signup_error_creation_limit_reached": "已達到帳戶創建限制", - "signup_error_username_taken": "用戶名 {{username}} 已被取用", + "reserve_dialog_checkbox_label": "保留主題並設定存取權限", + "signup_already_have_account": "已有帳戶?請登入!", + "signup_disabled": "註冊已停用", + "signup_error_creation_limit_reached": "已達到帳戶建立限制", + "signup_error_username_taken": "使用者名稱 {{username}} 已被取用", "signup_form_button_submit": "註冊", "signup_form_confirm_password": "確認密碼", "signup_form_password": "密碼", "signup_form_toggle_password_visibility": "切換密碼可見性", - "signup_form_username": "用戶名", - "signup_title": "創建一個 ntfy 帳戶", + "signup_form_username": "使用者名稱", + "signup_title": "建立 ntfy 帳戶", "subscribe_dialog_error_topic_already_reserved": "主題已保留", "subscribe_dialog_error_user_anonymous": "匿名", "subscribe_dialog_error_user_not_authorized": "未授權 {{username}} 使用者", "subscribe_dialog_login_button_login": "登入", - "subscribe_dialog_login_description": "本主題受密碼保護,請輸入用戶名和密碼以訂閱。", + "subscribe_dialog_login_description": "此主題受到密碼保護,請輸入使用者名稱和密碼以訂閱。", "subscribe_dialog_login_password_label": "密碼", - "subscribe_dialog_login_title": "請登錄", - "subscribe_dialog_login_username_label": "用戶名,例如 phil", - "subscribe_dialog_subscribe_base_url_label": "服務地址地址", + "subscribe_dialog_login_title": "請登入", + "subscribe_dialog_login_username_label": "使用者名稱,例如 phil", + "subscribe_dialog_subscribe_base_url_label": "服務 URL", "subscribe_dialog_subscribe_button_cancel": "取消", - "subscribe_dialog_subscribe_button_generate_topic_name": "生成名稱", + "subscribe_dialog_subscribe_button_generate_topic_name": "產生名稱", "subscribe_dialog_subscribe_button_subscribe": "訂閱", "subscribe_dialog_subscribe_description": "主題可能不受密碼保護,因此請選擇一個不容易被猜中的名字。訂閱後,你可以使用 PUT/POST 通知。", "subscribe_dialog_subscribe_title": "訂閱主題", - "subscribe_dialog_subscribe_topic_placeholder": "主題名,例如 phil_alerts", - "subscribe_dialog_subscribe_use_another_background_info": "當網頁程式未開啟, 將不會收到來自其他伺服器的通知", + "subscribe_dialog_subscribe_topic_placeholder": "主題名稱,例如 phil_alerts", + "subscribe_dialog_subscribe_use_another_background_info": "網頁應用程式未開啟時,不會收到來自其他伺服器的通知", "subscribe_dialog_subscribe_use_another_label": "使用其他伺服器", - "web_push_subscription_expiring_body": "開啟ntfy以繼續接收通知", + "web_push_subscription_expiring_body": "開啟 ntfy 以繼續接收通知", "web_push_subscription_expiring_title": "通知會被暫停", - "web_push_unknown_notification_body": "你可能需要開啟網頁來更新ntfy", + "web_push_unknown_notification_body": "你可能需要開啟網頁來更新 ntfy", "web_push_unknown_notification_title": "接收到不明通知", "account_basics_cannot_edit_or_delete_provisioned_user": "已佈建的使用者無法編輯或刪除", "account_tokens_table_cannot_delete_or_edit_provisioned_token": "無法編輯或刪除已佈建的權杖" From 9d3019004eafdd65451f10531bf91faf59993b1d Mon Sep 17 00:00:00 2001 From: internetezoo Date: Sun, 31 May 2026 15:50:10 +0200 Subject: [PATCH 03/46] Translated using Weblate (Hungarian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/hu/ --- web/public/static/langs/hu.json | 508 ++++++++++++++++++++++---------- 1 file changed, 345 insertions(+), 163 deletions(-) diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index f32b6e2f..8e17b691 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -1,227 +1,409 @@ { - "action_bar_send_test_notification": "Teszt értesítés küldése", - "action_bar_clear_notifications": "Összes értesítés törlése", - "alert_not_supported_description": "A böngésződ nem támogatja az értesítések fogadását", + "action_bar_send_test_notification": "Tesztértesítés küldése", + "action_bar_clear_notifications": "Az összes értesítés törlése", + "alert_not_supported_description": "A böngésző nem támogatja az értesítéseket", "action_bar_settings": "Beállítások", "action_bar_unsubscribe": "Leiratkozás", - "message_bar_type_message": "Írd ide az üzenetet", - "message_bar_error_publishing": "Hiba történt az értesítés elküldése közben", - "nav_button_all_notifications": "Összes értesítés", + "message_bar_type_message": "Írj ide egy üzenetet", + "message_bar_error_publishing": "Hiba az értesítés közzétételénél", + "nav_button_all_notifications": "Minden értesítés", "nav_topics_title": "Feliratkozott témák", "alert_notification_permission_required_title": "Az értesítések le vannak tiltva", - "alert_notification_permission_required_description": "Engedélyezd a böngésződnek, hogy asztali értesítéseket jelenítsen meg", + "alert_notification_permission_required_description": "Engedélyezze a böngészőjében az asztali értesítések megjelenítését", "nav_button_settings": "Beállítások", "nav_button_documentation": "Dokumentáció", - "nav_button_publish_message": "Értesítés küldése", - "alert_notification_permission_required_button": "Engedélyezés", - "alert_not_supported_title": "Az értesítések nincsenek támogatva", - "notifications_copied_to_clipboard": "Vágólapra másolva", + "nav_button_publish_message": "Értesítés közzététele", + "alert_notification_permission_required_button": "Jelentkezz most", + "alert_not_supported_title": "Az értesítések nem támogatottak", + "notifications_copied_to_clipboard": "A vágólapra másolva", "notifications_tags": "Címkék", - "notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét", + "notifications_attachment_copy_url_title": "Copy attachment URL to clipboard", "notifications_attachment_copy_url_button": "URL másolása", - "notifications_attachment_open_title": "Menjen a(z) {{url}} címre", - "notifications_attachment_open_button": "Csatolmány megnyitása", - "notifications_attachment_link_expired": "A letöltési link lejárt", - "notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le", - "nav_button_subscribe": "Feliratkozás témára", - "notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét", - "notifications_actions_open_url_title": "Menjen a(z) {{url}} címre", - "notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban", - "notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre", - "notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.", - "notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.", - "notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.", - "notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.", - "publish_dialog_message_published": "Értesítés elküldve", + "notifications_attachment_open_title": "Ugrás a{{url}}-ra", + "notifications_attachment_open_button": "Melléklet megnyitása", + "notifications_attachment_link_expired": "A letöltési link érvényessége lejárt", + "notifications_attachment_link_expires": "A link érvényessége lejár:{{date}}", + "nav_button_subscribe": "Iratkozz fel a témára", + "notifications_click_copy_url_title": "A link URL-jét a vágólapra másolja", + "notifications_actions_open_url_title": "Ugrás a{{url}}-ra", + "notifications_actions_not_supported": "Ez a művelet nem támogatott a webalkalmazásban", + "notifications_actions_http_request_title": "HTTP-kérés küldése {{method}} címre {{url}}", + "notifications_none_for_topic_title": "Erre a témára még nem kaptál értesítést.", + "notifications_none_for_any_title": "Nem érkezett hozzád értesítés.", + "notifications_none_for_any_description": "Ha értesítéseket szeretnél küldeni egy témához, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére. Íme egy példa az egyik témádra vonatkozóan.", + "notifications_no_subscriptions_title": "Úgy tűnik, még nincs előfizetésed.", + "publish_dialog_message_published": "Értesítés közzététele", "notifications_example": "Példa", - "notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.", + "notifications_no_subscriptions_description": "Kattints a „{{linktext}}” linkre egy téma létrehozásához vagy feliratkozáshoz. Ezt követően PUT vagy POST kéréssel küldhetsz üzeneteket, és itt fogod megkapni az értesítéseket.", "publish_dialog_priority_low": "Alacsony prioritás", - "publish_dialog_priority_default": "Közepes prioritás", - "publish_dialog_priority_high": "Magas prioritás", - "notifications_more_details": "További információkért keresd fel a weboldalunkat vagy olvasd el a dokumentációt.", - "publish_dialog_title_no_topic": "Értesítés küldése", - "publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)", - "publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt", - "publish_dialog_priority_min": "Legkisebb prioritás", - "publish_dialog_base_url_label": "A szolgáltatás URL-e", - "publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com", - "publish_dialog_topic_label": "Téma neve", - "publish_dialog_priority_max": "Legmagasabb prioritás", - "publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai", + "publish_dialog_priority_default": "Alapértelmezett prioritás", + "publish_dialog_priority_high": "Kiemelt fontosságú", + "notifications_more_details": "További információkért látogasson el aweboldalra, vagy tekintse meg avagy adokumentációt.", + "publish_dialog_title_no_topic": "Értesítés közzététele", + "publish_dialog_attachment_limits_file_and_quota_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot és kvótát,{{remainingBytes}}maradt", + "publish_dialog_attachment_limits_quota_reached": "meghaladja a kvótát, {{remainingBytes}} maradt", + "publish_dialog_priority_min": "Minimális prioritás", + "publish_dialog_base_url_label": "Szolgáltatás URL-je", + "publish_dialog_base_url_placeholder": "Szolgáltatás URL-címe, pl. https://example.com", + "publish_dialog_topic_label": "A téma neve", + "publish_dialog_priority_max": "Legfőbb prioritás", + "publish_dialog_topic_placeholder": "Téma neve, pl. phil_alerts", "publish_dialog_title_label": "Cím", - "publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely", + "publish_dialog_title_placeholder": "Értesítés címe, pl. Lemezterület-figyelmeztetés", "publish_dialog_message_label": "Üzenet", "publish_dialog_message_placeholder": "Írj ide egy üzenetet", "publish_dialog_tags_label": "Címkék", - "publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup", - "publish_dialog_priority_label": "Prioritás", - "publish_dialog_click_label": "URL", - "publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak", - "publish_dialog_email_label": "Email", - "publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com", - "publish_dialog_attach_label": "Csatolmány URL-e", + "publish_dialog_tags_placeholder": "Vesszővel elválasztott címkék listája, pl.: warning, srv1-backup", + "publish_dialog_priority_label": "Elsőbbség", + "publish_dialog_click_label": "Kattintson az URL-re", + "publish_dialog_click_placeholder": "Az értesítésre kattintáskor megnyíló URL", + "publish_dialog_email_label": "E-mail", + "publish_dialog_email_placeholder": "A bejelentés továbbításának címe, pl.: phil@example.com", + "publish_dialog_attach_label": "A melléklet URL-címe", "publish_dialog_filename_label": "Fájlnév", - "publish_dialog_filename_placeholder": "Csatolmány fájlneve", + "publish_dialog_filename_placeholder": "A melléklet fájlneve", "publish_dialog_delay_label": "Késleltetés", - "publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)", - "publish_dialog_other_features": "Egyéb lehetőségek:", - "publish_dialog_chip_click_label": "Kattintási URL", + "publish_dialog_delay_placeholder": "Szállítás késleltetése, pl. {{unixTimestamp}}, {{relativeTime}}vagy „{{naturalLanguage}}” (csak angolul)", + "publish_dialog_other_features": "Egyéb jellemzők:", + "publish_dialog_chip_click_label": "Kattintson az URL-re", "publish_dialog_chip_attach_file_label": "Helyi fájl csatolása", - "publish_dialog_chip_delay_label": "Késleltetett kézbesítés", - "publish_dialog_chip_topic_label": "Téma megváltoztatása", - "publish_dialog_button_cancel_sending": "Küldés megállítása", - "publish_dialog_button_cancel": "Mégsem", - "publish_dialog_checkbox_publish_another": "Küldök még egyet", + "publish_dialog_chip_delay_label": "Szállítás késleltetése", + "publish_dialog_chip_topic_label": "Téma váltása", + "publish_dialog_button_cancel_sending": "Elküldés visszavonása", + "publish_dialog_button_cancel": "Mégse", + "publish_dialog_checkbox_publish_another": "Újabb közzététel", "publish_dialog_attached_file_title": "Csatolt fájl:", - "publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve", - "publish_dialog_drop_file_here": "Ejtsd ide a fájlt", - "emoji_picker_search_placeholder": "Emoji keresése", - "publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a dokumentációt.", + "publish_dialog_attached_file_filename_placeholder": "A melléklet fájlneve", + "publish_dialog_drop_file_here": "Helyezze ide a fájlt", + "emoji_picker_search_placeholder": "Emoji keresés", + "publish_dialog_details_examples_description": "Példákért és az összes küldési funkció részletes leírásáért kérjük, olvassa el a dokumentációt.", "subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert", - "subscribe_dialog_subscribe_button_subscribe": "Feliratkozás", - "subscribe_dialog_login_title": "Be kell jelentkezni", - "subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.", - "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", - "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", + "subscribe_dialog_subscribe_button_subscribe": "Iratkozz fel", + "subscribe_dialog_login_title": "Bejelentkezés szükséges", + "subscribe_dialog_subscribe_description": "A témák nem jelszóval védhetők, ezért válasszon olyan nevet, amelyet nem könnyű kitalálni. A feliratkozás után PUT/POST értesítéseket küldhet.", + "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Kérjük, adja meg a felhasználónevét és a jelszavát a feliratkozáshoz.", + "subscribe_dialog_login_username_label": "Felhasználónév, pl. phil", "subscribe_dialog_login_password_label": "Jelszó", "common_back": "Vissza", - "subscribe_dialog_login_button_login": "Belépés", + "subscribe_dialog_login_button_login": "Bejelentkezés", "subscribe_dialog_error_user_anonymous": "névtelen", - "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", - "prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül", - "prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg", - "prefs_notifications_min_priority_any": "Bármilyen prioritás", - "prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb", - "prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás", - "prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás", - "prefs_notifications_sound_title": "Értesítés hangja", - "prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek", - "prefs_notifications_sound_no_sound": "Hang nélkül", - "prefs_notifications_delete_after_one_week": "1 hét után", - "prefs_notifications_delete_after_one_month": "1 hónap után", - "prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve", - "prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése", - "prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése", - "prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.", + "subscribe_dialog_error_user_not_authorized": "A{{username}}felhasználó nem rendelkezik jogosultsággal", + "prefs_notifications_min_priority_description_any": "Az összes értesítés megjelenítése, prioritástól függetlenül", + "prefs_notifications_min_priority_description_max": "Értesítések megjelenítése, ha a prioritás 5 (maximális)", + "prefs_notifications_min_priority_any": "Bármely prioritás", + "prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás és annál magasabb", + "prefs_notifications_min_priority_high_and_higher": "Magas prioritás és annál magasabb", + "prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritású", + "prefs_notifications_sound_title": "Értesítési hang", + "prefs_notifications_sound_description_none": "Az értesítések érkezésekor nem hallatszik hang", + "prefs_notifications_sound_no_sound": "Nincs hang", + "prefs_notifications_delete_after_one_week": "Egy hét elteltével", + "prefs_notifications_delete_after_one_month": "Egy hónap elteltével", + "prefs_notifications_delete_after_never_description": "Az értesítések soha nem kerülnek automatikusan törlésre", + "prefs_notifications_delete_after_three_hours_description": "Az értesítések három óra elteltével automatikusan törlődnek", + "prefs_notifications_delete_after_one_day_description": "Az értesítések egy nap elteltével automatikusan törlődnek", + "prefs_users_description": "Itt adhat hozzá vagy távolíthat el felhasználókat a védett témákhoz. Felhívjuk figyelmét, hogy a felhasználónév és a jelszó a böngésző helyi tárolójában kerülnek elmentésre.", "prefs_users_table_user_header": "Felhasználó", - "prefs_users_table_base_url_header": "Szerver címe", + "prefs_users_table_base_url_header": "Szolgáltatás URL-je", "prefs_users_dialog_title_edit": "Felhasználó szerkesztése", - "prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi", + "prefs_users_dialog_username_label": "Felhasználónév, pl. phil", "prefs_users_dialog_password_label": "Jelszó", "common_add": "Hozzáadás", - "prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh", + "prefs_users_dialog_base_url_label": "Szolgáltatási URL, pl. https://ntfy.sh", "notifications_loading": "Értesítések betöltése …", - "publish_dialog_progress_uploading": "Feltöltés …", - "notifications_click_copy_url_button": "Hivatkozás másolása", - "notifications_click_open_button": "Hivatkozás megnyitása", - "publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …", - "notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.", - "prefs_notifications_delete_after_one_day": "1 nap után", - "publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk", - "publish_dialog_chip_email_label": "Továbbítás email-ben", - "publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel", + "publish_dialog_progress_uploading": "Feltöltés…", + "notifications_click_copy_url_button": "Link másolása", + "notifications_click_open_button": "Link megnyitása", + "publish_dialog_progress_uploading_detail": "{{loaded}}/{{total}}feltöltése ({{percent}}%) …", + "notifications_none_for_topic_description": "Ha értesítéseket szeretnél küldeni erre a témára, egyszerűen hajts végre egy PUT vagy POST műveletet a téma URL-jére.", + "prefs_notifications_delete_after_one_day": "Egy nap elteltével", + "publish_dialog_attach_placeholder": "Fájl csatolása URL-címen keresztül, pl. https://f-droid.org/F-Droid.apk", + "publish_dialog_chip_email_label": "Továbbítás e-mailben", + "publish_dialog_chip_attach_url_label": "Fájl csatolása URL-címen keresztül", "publish_dialog_button_send": "Küldés", - "subscribe_dialog_subscribe_title": "Feliratkozás témára", - "subscribe_dialog_subscribe_button_cancel": "Mégsem", - "prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás", - "prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb", - "prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb", - "prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése", + "subscribe_dialog_subscribe_title": "Iratkozz fel a témára", + "subscribe_dialog_subscribe_button_cancel": "Mégse", + "prefs_notifications_min_priority_title": "Legalacsonyabb prioritás", + "prefs_notifications_min_priority_description_x_or_higher": "Értesítéseket jelenít meg, ha a prioritás {{number}} ({{name}}) vagy annál magasabb", + "prefs_notifications_min_priority_default_and_higher": "Alapértelmezett prioritás és annál magasabb", + "prefs_notifications_delete_after_one_week_description": "Az értesítések egy hét elteltével automatikusan törlődnek", "prefs_users_add_button": "Felhasználó hozzáadása", - "subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai", + "subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl. phil_alerts", "prefs_notifications_title": "Értesítések", - "error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása", - "prefs_notifications_delete_after_title": "Régi értesítések törlése", - "prefs_notifications_delete_after_three_hours": "3 óra után", - "error_boundary_title": "Jaj ne, az ntfy összeomlott", + "error_boundary_button_copy_stack_trace": "A veremnyomtatvány másolása", + "prefs_notifications_delete_after_title": "Értesítések törlése", + "prefs_notifications_delete_after_three_hours": "Három óra múlva", + "error_boundary_title": "Jaj, ne, az ntfy összeomlott", "prefs_notifications_delete_after_never": "Soha", - "prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése", + "prefs_notifications_delete_after_one_month_description": "Az értesítések egy hónap elteltével automatikusan törlődnek", "prefs_appearance_title": "Megjelenés", - "priority_default": "közepes", + "priority_default": "alapértelmezett", "priority_high": "magas", - "priority_max": "legmagasabb", - "priority_min": "legkisebb", - "error_boundary_gathering_info": "Több információ…", - "publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})", + "priority_max": "legnagyobb", + "priority_min": "Nekem", + "error_boundary_gathering_info": "További információk …", + "publish_dialog_attachment_limits_file_reached": "meghaladja a{{fileSizeLimit}}fájlkorlátot", "prefs_users_title": "Felhasználók kezelése", - "common_cancel": "Mégsem", + "common_cancel": "Mégse", "common_save": "Mentés", "prefs_users_dialog_title_add": "Felhasználó hozzáadása", "prefs_appearance_language_title": "Nyelv", "priority_low": "alacsony", - "error_boundary_stack_trace": "Verem nyomkövetés", - "publish_dialog_title_topic": "A {{topic}} téma értesítése", - "prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni", - "error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.
Ha van egy perced, jelentsd be GitHubon, vagy tudasd velünk Discordon, vagy Matrixon.", - "action_bar_show_menu": "Menü mutatása", - "action_bar_toggle_mute": "Üzenetek némítása/bekapcsolása", + "error_boundary_stack_trace": "Hibajelentés", + "publish_dialog_title_topic": "Közzététel a {{topic}}-ban", + "prefs_notifications_sound_description_some": "Az értesítések érkezéskor a {{sound}} hangot játsszák le", + "error_boundary_description": "Ez természetesen nem lenne szabad, hogy megtörténjen. Nagyon sajnáljuk a kellemetlenséget.
Ha van egy perced, kérlek, jelentsd be a hibát a GitHubon, vagy értesíts minket a Discordon vagy a Matrixon.", + "action_bar_show_menu": "Menü megjelenítése", + "action_bar_toggle_mute": "Értesítések némítása/némításának feloldása", "notifications_list_item": "Értesítés", - "error_boundary_unsupported_indexeddb_description": "A ntfy web alkalmazás működéséhez szükséges az IndexedDB funkció, az ön böngészője nem támogatja az IndexedDB használatát privát böngészés közben.

Miközben privát mód sajnos nem lehetséges, szeretnénk értesíteni hogy magabiztosan használhatja normál módban mert a böngésző minden adatot az ön gépén tárol. Tovább tájékozódhat ezen a Github oldalon, vagy beszéljen velünk Discord-on vagy Matrix-on.", - "notifications_priority_x": "Prioritás {{prioritás}}", - "message_bar_show_dialog": "Küldött üzenetek megjelenítése", + "error_boundary_unsupported_indexeddb_description": "Az ntfy webalkalmazás működéséhez szükség van az IndexedDB-re, a böngésződ azonban nem támogatja az IndexedDB-t inkognitó módban.

Bár ez sajnálatos, valójában nem is lenne túl értelmes az ntfy webalkalmazást inkognitó módban használni, mivel minden a böngésző tárolójában kerül elmentésre. További információkat erről a GitHub-problémában találsz, vagy lépj kapcsolatba velünk a Discordon vagy a Matrixon.", + "notifications_priority_x": "Prioritás {{priority}}", + "message_bar_show_dialog": "Közzétételi párbeszédpanel megjelenítése", "action_bar_logo_alt": "ntfy logó", - "action_bar_toggle_action_menu": "Tevékenységkezelő nyitása/zárása", - "message_bar_publish": "Üzenet küldése", - "nav_button_muted": "Értesítések némítva", - "nav_button_connecting": "csatlakozás", - "notifications_list": "Értesítés lista", - "notifications_mark_read": "Jelölés olvasottként", + "action_bar_toggle_action_menu": "Műveleti menü megnyitása/bezárása", + "message_bar_publish": "Üzenet közzététele", + "nav_button_muted": "Értesítések elnémítva", + "nav_button_connecting": "összekötő", + "notifications_list": "Értesítések listája", + "notifications_mark_read": "Olvasottként jelölés", "notifications_delete": "Törlés", "notifications_new_indicator": "Új értesítés", - "notifications_attachment_image": "Csatolt kép", - "notifications_attachment_file_image": "Kép fájl", - "notifications_attachment_file_video": "Videó fájl", - "notifications_attachment_file_audio": "Hang fájl", - "notifications_attachment_file_app": "Android alkalmazás fájl", + "notifications_attachment_image": "Melléklet kép", + "notifications_attachment_file_image": "képfájl", + "notifications_attachment_file_video": "videofájl", + "notifications_attachment_file_audio": "hangfájl", + "notifications_attachment_file_app": "Android-alkalmazásfájl", "notifications_attachment_file_document": "egyéb dokumentum", "publish_dialog_emoji_picker_show": "Emoji kiválasztása", "publish_dialog_topic_reset": "Téma visszaállítása", - "publish_dialog_click_reset": "URL kattintás törlése", - "publish_dialog_email_reset": "Email továbbítás törlése", - "publish_dialog_attach_reset": "Csatolt URL törlése", - "publish_dialog_delay_reset": "Késleltetett kézbesítés törlése", - "publish_dialog_attached_file_remove": "Csatolt fájl törlése", + "publish_dialog_click_reset": "Az URL-re kattintás eltávolítása", + "publish_dialog_email_reset": "Az e-mail továbbításának megszüntetése", + "publish_dialog_attach_reset": "A melléklet URL-jének eltávolítása", + "publish_dialog_delay_reset": "A késleltetett kézbesítés eltávolítása", + "publish_dialog_attached_file_remove": "A csatolt fájl eltávolítása", "emoji_picker_search_clear": "Keresés törlése", - "prefs_notifications_sound_play": "Kijelölt hang lejátszása", - "prefs_users_table": "Felhasználó táblázat", + "prefs_notifications_sound_play": "A kiválasztott hang lejátszása", + "prefs_users_table": "Felhasználók táblázata", "prefs_users_edit_button": "Felhasználó szerkesztése", "prefs_users_delete_button": "Felhasználó törlése", - "error_boundary_unsupported_indexeddb_title": "Privát böngészés nem támogatott", - "subscribe_dialog_subscribe_base_url_label": "Szolgáltató URL", + "error_boundary_unsupported_indexeddb_title": "A magánböngészés nem támogatott", + "subscribe_dialog_subscribe_base_url_label": "Szolgáltatás URL-je", "signup_form_username": "Felhasználónév", "signup_form_password": "Jelszó", - "signup_form_button_submit": "Regisztráció", + "signup_form_button_submit": "Regisztrálj", "login_form_button_submit": "Bejelentkezés", - "login_link_signup": "Regisztráció", - "login_disabled": "Bejelentkezés kikapcsolva", - "action_bar_change_display_name": "Megjelenített név módosítása", + "login_link_signup": "Regisztrálj", + "login_disabled": "A bejelentkezés le van tiltva", + "action_bar_change_display_name": "A megjelenített név módosítása", "action_bar_profile_logout": "Kijelentkezés", "action_bar_sign_in": "Bejelentkezés", - "action_bar_sign_up": "Regisztráció", + "action_bar_sign_up": "Regisztrálj", "action_bar_profile_title": "Profil", "nav_button_account": "Fiók", - "common_copy_to_clipboard": "Másolás vágólapra", - "action_bar_reservation_limit_reached": "Limit elérve", - "login_title": "Jelentkezz be a ntfy felhasználódba", - "signup_title": "Hozz létre egy ntfy felhasználói fiókot", + "common_copy_to_clipboard": "Másolás a vágólapra", + "action_bar_reservation_limit_reached": "Elérte a határt", + "login_title": "Jelentkezzen be az ntfy-fiókjába", + "signup_title": "Hozzon létre egy ntfy-fiókot", "signup_form_confirm_password": "Jelszó megerősítése", - "signup_already_have_account": "Már van felhasználód? Jelentkezz be!", + "signup_already_have_account": "Van már fiókod? Jelentkezz be!", "action_bar_account": "Fiók", "action_bar_profile_settings": "Beállítások", - "signup_error_username_taken": "A felhasználónév {{username}} már foglalt", - "signup_error_creation_limit_reached": "Felhasználói regisztráció limit elérve", + "signup_error_username_taken": "A{{username}}felhasználónév már foglalt", + "signup_error_creation_limit_reached": "Elérte a fiók létrehozásának korlátját", "action_bar_mute_notifications": "Értesítések némítása", - "action_bar_unmute_notifications": "Értesítések némításának feloldása", - "alert_notification_permission_denied_title": "Az értesítések blokkolva vannak", - "alert_notification_permission_denied_description": "Kérjük kapcsold őket vissza a böngésződben", - "alert_notification_ios_install_required_title": "iOS telepítés szükséges", - "alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a Notifications API korlátozása.", - "signup_form_toggle_password_visibility": "Jelszó láthatóságának kapcsolása", + "action_bar_unmute_notifications": "Értesítések hangjának visszaállítása", + "alert_notification_permission_denied_title": "Az értesítések letiltva vannak", + "alert_notification_permission_denied_description": "Kérjük, kapcsolja be őket újra a böngészőjében", + "alert_notification_ios_install_required_title": "iOS-telepítés szükséges", + "alert_not_supported_context_description": "Az értesítések kizárólag HTTPS-en keresztül támogatottak. Ez a Értesítési APIkorlátozása.", + "signup_form_toggle_password_visibility": "A jelszó láthatóságának beállítása", "signup_disabled": "A regisztráció le van tiltva", - "action_bar_reservation_add": "Téma fenntartása", + "action_bar_reservation_add": "Téma elmentése", "action_bar_reservation_edit": "Foglalás módosítása", "action_bar_reservation_delete": "Foglalás törlése", - "nav_upgrade_banner_label": "Frissítés ntfy Pro-ra", - "nav_upgrade_banner_description": "Témák, több üzenet és e-mail, valamint nagyobb mellékletek megőrzése", - "alert_notification_ios_install_required_description": "Kattintson a Megosztás ikonra, majd a Hozzáadás a kezdőképernyőhöz gombra, hogy engedélyezze az értesítéseket iOS rendszeren" + "nav_upgrade_banner_label": "Frissíts az ntfy Pro-ra", + "nav_upgrade_banner_description": "További témák, több üzenet és e-mail, valamint nagyobb mellékletek", + "alert_notification_ios_install_required_description": "Kattints a Megosztás ikonra, majd a „Hozzáadás a kezdőképernyőhöz” gombra az értesítések engedélyezéséhez iOS rendszeren", + "notifications_actions_failed_notification": "Sikertelen művelet", + "display_name_dialog_title": "A megjelenített név módosítása", + "display_name_dialog_description": "Állítson be egy alternatív nevet egy témához, amely az előfizetési listában jelenik meg. Ez megkönnyíti a bonyolult nevű témák azonosítását.", + "display_name_dialog_placeholder": "Megjelenítendő név", + "reserve_dialog_checkbox_label": "Téma lefoglalása és a hozzáférés beállítása", + "publish_dialog_call_label": "Telefonhívás", + "publish_dialog_call_item": "Hívja a{{number}}telefonszámot", + "publish_dialog_call_reset": "Hívás törlése", + "publish_dialog_chip_call_label": "Telefonhívás", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Nincs ellenőrzött telefonszám", + "publish_dialog_checkbox_markdown": "Markdown formátum", + "subscribe_dialog_subscribe_use_another_background_info": "A webalkalmazás bezárása esetén a többi szerverről érkező értesítések nem érkeznek meg", + "subscribe_dialog_subscribe_button_generate_topic_name": "Név generálása", + "subscribe_dialog_error_topic_already_reserved": "A téma már lefoglalva", + "account_basics_title": "Fiók", + "account_basics_username_title": "Felhasználónév", + "account_basics_username_description": "Hé, ez te vagy ❤", + "account_basics_username_admin_tooltip": "Ön rendszergazda", + "account_basics_password_title": "Jelszó", + "account_basics_password_description": "Fiókjának jelszavának módosítása", + "account_basics_password_dialog_title": "Jelszó módosítása", + "account_basics_password_dialog_current_password_label": "Jelenlegi jelszó", + "account_basics_password_dialog_new_password_label": "Új jelszó", + "account_basics_password_dialog_confirm_password_label": "Jelszó megerősítése", + "account_basics_password_dialog_button_submit": "Jelszó módosítása", + "account_basics_password_dialog_current_password_incorrect": "Helytelen jelszó", + "account_basics_phone_numbers_title": "Telefonszámok", + "account_basics_phone_numbers_dialog_description": "A hívásértesítési funkció használatához legalább egy telefonszámot hozzá kell adnia és igazolnia kell. Az igazolás SMS-ben vagy telefonhívás útján történhet.", + "account_basics_phone_numbers_description": "Telefonos értesítések esetén", + "account_basics_phone_numbers_no_phone_numbers_yet": "Még nincs telefonszám", + "account_basics_phone_numbers_copied_to_clipboard": "A telefonszámot a vágólapra másoltam", + "account_basics_phone_numbers_dialog_title": "Telefonszám hozzáadása", + "account_basics_phone_numbers_dialog_number_label": "Telefonszám", + "account_basics_phone_numbers_dialog_number_placeholder": "pl. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "SMS küldése", + "account_basics_phone_numbers_dialog_verify_button_call": "Hívj fel", + "account_basics_phone_numbers_dialog_code_label": "Ellenőrző kód", + "account_basics_phone_numbers_dialog_code_placeholder": "pl. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Kód megerősítése", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Hívás", + "account_basics_cannot_edit_or_delete_provisioned_user": "A hozzárendelt felhasználót nem lehet szerkeszteni vagy törölni", + "account_usage_title": "Használat", + "account_usage_of_limit": "a{{limit}}", + "account_usage_unlimited": "Korlátlan", + "account_usage_limits_reset_daily": "A használati korlátok minden nap éjfélkor (UTC) visszaállnak", + "account_basics_tier_title": "Számlatípus", + "account_basics_tier_description": "Fiókod erősségi szintje", + "account_basics_tier_admin": "Admin", + "account_basics_tier_admin_suffix_with_tier": "(a{{tier}}szinttel)", + "account_basics_tier_admin_suffix_no_tier": "(nincs besorolás)", + "account_basics_tier_basic": "Alapvető", + "account_basics_tier_free": "Ingyenes", + "account_basics_tier_interval_monthly": "havonta", + "account_basics_tier_interval_yearly": "éves szinten", + "account_basics_tier_upgrade_button": "Frissíts Pro verzióra", + "account_basics_tier_change_button": "Változás", + "account_basics_tier_paid_until": "Az előfizetés{{date}}-ig fizetve, és automatikusan megújul", + "account_basics_tier_payment_overdue": "A fizetési határidő lejárt. Kérjük, frissítse a fizetési módját, ellenkező esetben fiókját hamarosan alacsonyabb szintre soroljuk át.", + "account_basics_tier_canceled_subscription": "Előfizetését töröltük, és{{date}}-tól ingyenes fiókra váltunk.", + "account_basics_tier_manage_billing_button": "Számlázás kezelése", + "account_usage_messages_title": "Közzétett üzenetek", + "account_usage_emails_title": "Elküldött e-mailek", + "account_usage_calls_title": "Kezdeményezett telefonhívások", + "account_usage_calls_none": "Ebből a fiókból nem lehet telefonálni", + "account_usage_reservations_title": "Fenntartott témák", + "account_usage_reservations_none": "Ehhez a fiókhoz nincs fenntartott téma", + "account_usage_attachment_storage_title": "Mellékletek tárolása", + "account_usage_attachment_storage_description": "{{filesize}}fájlonként, törlésre kerül{{expiry}}után", + "account_usage_basis_ip_description": "A fiók használati statisztikái és korlátai az Ön IP-címén alapulnak, ezért előfordulhat, hogy más felhasználókkal is megosztásra kerülnek. A fent feltüntetett korlátok a jelenlegi sávszélesség-korlátozások alapján számított hozzávetőleges értékek.", + "account_usage_cannot_create_portal_session": "A számlázási portál nem nyitható meg", + "account_delete_title": "Fiók törlése", + "account_delete_description": "Fiókjának végleges törlése", + "account_delete_dialog_description": "Ezzel véglegesen törlöd a fiókodat, beleértve a szerveren tárolt összes adatot is. A törlés után a felhasználóneved 7 napig nem lesz elérhető. Ha biztosan folytatni szeretnéd, kérjük, erősítsd meg a jelszavadat az alábbi mezőben.", + "account_delete_dialog_label": "Jelszó", + "account_delete_dialog_button_cancel": "Mégse", + "account_delete_dialog_button_submit": "Fiók végleges törlése", + "account_delete_dialog_billing_warning": "A fiók törlésével a fizetési előfizetés is azonnal megszűnik. Ezt követően már nem fogsz hozzáférni a fizetési irányítópanelhez.", + "account_upgrade_dialog_title": "Fiókcsomag módosítása", + "account_upgrade_dialog_interval_monthly": "Havi", + "account_upgrade_dialog_interval_yearly": "Évente", + "account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}% mentése", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "akár {{discount}}%-os megtakarítás", + "account_upgrade_dialog_cancel_warning": "Ezzel előfizetése megszűnik, és fiókja alacsonyabb szintre kerül {{date}}. Ezen a napon a témákhoz tartozó foglalások, valamint a szerveren tárolt üzenetek törlésre kerülnek.", + "account_upgrade_dialog_proration_info": "Arányos elszámolás: Fizetős csomagok közötti áttérés esetén az árkülönbözetetazonnal felszámítjuk. Alacsonyabb csomagra való áttérés esetén a fennmaradó egyenleget a jövőbeli számlázási időszakok fedezésére használjuk fel.", + "account_upgrade_dialog_reservations_warning_one": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt kérjük, törölj legalább egy foglalást. A foglalásokat a Beállításokmenüpontban törölheted.", + "account_upgrade_dialog_reservations_warning_other": "A kiválasztott csomag kevesebb témafoglalást engedélyez, mint a jelenlegi csomagod. A csomagváltás előtt kérjük, törölj legalább {{count}} foglalást. A foglalásokat a Beállításokmenüpontban törölheted.", + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} fenntartott téma", + "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} fenntartott témák", + "account_upgrade_dialog_tier_features_no_reservations": "Nincsenek fenntartott témák", + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} napi üzenet", + "account_upgrade_dialog_tier_features_messages_other": "{{messages}} napi üzenetek", + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} napi hírlevél", + "account_upgrade_dialog_tier_features_emails_other": "{{emails}} napi e-mailek", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} napi telefonhívás", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} napi telefonhívás", + "account_upgrade_dialog_tier_features_no_calls": "Tilos telefonálni", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} fájlonként", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} teljes tárhely", + "account_upgrade_dialog_tier_price_per_month": "hónap", + "account_upgrade_dialog_tier_price_billed_monthly": "{{price}}évente. Havonta számlázzuk.", + "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} éves számlázás. Megtakarítás: {{save}}.", + "account_upgrade_dialog_tier_selected_label": "Kiválasztott", + "account_upgrade_dialog_tier_current_label": "Jelenlegi", + "account_upgrade_dialog_billing_contact_email": "Számlázással kapcsolatos kérdéseivel kérjük, forduljon közvetlenül hozzánk.", + "account_upgrade_dialog_billing_contact_website": "Számlázással kapcsolatos kérdéseivel kérjük, keresse fel aweboldalunkat.", + "account_upgrade_dialog_button_cancel": "Mégse", + "account_upgrade_dialog_button_redirect_signup": "Regisztrálj most", + "account_upgrade_dialog_button_pay_now": "Fizessen most, és iratkozzon fel", + "account_upgrade_dialog_button_cancel_subscription": "Előfizetés lemondása", + "account_upgrade_dialog_button_update_subscription": "Előfizetés frissítése", + "account_tokens_title": "Hozzáférési tokenek", + "account_tokens_description": "Az ntfy API-n keresztül történő közzététel és feliratkozás során használjon hozzáférési tokeneket, így nem kell megadnia a fiókja bejelentkezési adatait. További információkért tekintse meg a dokumentációt.", + "account_tokens_table_token_header": "Token", + "account_tokens_table_label_header": "Címke", + "account_tokens_table_last_access_header": "Utolsó hozzáférés", + "account_tokens_table_expires_header": "Lejár", + "account_tokens_table_never_expires": "Soha nem jár le", + "account_tokens_table_current_session": "Aktuális böngészőmunkamenet", + "account_tokens_table_copied_to_clipboard": "Az hozzáférési token másolva", + "account_tokens_table_cannot_delete_or_edit": "A jelenlegi munkamenet-tokent nem lehet szerkeszteni vagy törölni", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "A létrehozott token nem szerkeszthető vagy törölhető", + "account_tokens_table_create_token_button": "Hozzon létre hozzáférési tokent", + "account_tokens_table_last_origin_tooltip": "A{{ip}}IP-címről kattintson a kereséshez", + "account_tokens_dialog_title_create": "Hozzon létre hozzáférési tokent", + "account_tokens_dialog_title_edit": "Hozzáférési token szerkesztése", + "account_tokens_dialog_title_delete": "Hozzáférési token törlése", + "account_tokens_dialog_label": "Címke, pl. Radarr értesítések", + "account_tokens_dialog_button_create": "Token létrehozása", + "account_tokens_dialog_button_update": "Token frissítése", + "account_tokens_dialog_button_cancel": "Mégse", + "account_tokens_dialog_expires_label": "Az hozzáférési token érvényessége", + "account_tokens_dialog_expires_unchanged": "A lejárati dátumot ne módosítsa", + "account_tokens_dialog_expires_x_hours": "A token érvényessége {{hours}} óra múlva lejár", + "account_tokens_dialog_expires_x_days": "A token érvényessége {{days}} nap múlva lejár", + "account_tokens_dialog_expires_never": "A token soha nem jár le", + "account_tokens_delete_dialog_title": "Hozzáférési token törlése", + "account_tokens_delete_dialog_description": "Mielőtt törölne egy hozzáférési tokent, győződjön meg arról, hogy egyetlen alkalmazás vagy szkript sem használja azt éppen. Ez a művelet visszafordíthatatlan.", + "account_tokens_delete_dialog_submit_button": "A token végleges törlése", + "prefs_notifications_web_push_title": "Háttérben futó értesítések", + "prefs_notifications_web_push_enabled_description": "Az értesítések akkor is beérkeznek, ha a webalkalmazás nem fut (Web Push segítségével)", + "prefs_notifications_web_push_disabled_description": "Értesítéseket kapunk, amikor a webalkalmazás fut (WebSocket-en keresztül)", + "prefs_notifications_web_push_enabled": "Engedélyezve a{{server}}esetében", + "prefs_notifications_web_push_disabled": "Fogyatékkal élők", + "prefs_users_description_no_sync": "A felhasználónevek és jelszavak nem kerülnek szinkronizálásra a fiókjával.", + "prefs_users_table_cannot_delete_or_edit": "A bejelentkezett felhasználót nem lehet törölni vagy szerkeszteni", + "prefs_appearance_theme_title": "Téma", + "prefs_appearance_theme_system": "Rendszer (alapértelmezett)", + "prefs_appearance_theme_dark": "Sötét mód", + "prefs_appearance_theme_light": "Világos mód", + "prefs_reservations_title": "Fenntartott témák", + "prefs_reservations_description": "Itt foglalhat le témákat személyes használatra. A téma lefoglalásával tulajdonjogot szerez a témára, és megadhatja a többi felhasználó számára a témához való hozzáférési jogosultságokat.", + "prefs_reservations_limit_reached": "Elérted a fenntartott témák számának korlátját.", + "prefs_reservations_add_button": "Foglalt téma hozzáadása", + "prefs_reservations_edit_button": "Téma szerkesztése", + "prefs_reservations_delete_button": "A téma hozzáférésének visszaállítása", + "prefs_reservations_table": "Foglalt témák táblázata", + "prefs_reservations_table_topic_header": "Téma", + "prefs_reservations_table_access_header": "Hozzáférés", + "prefs_reservations_table_everyone_deny_all": "Csak én tudok hírleveleket kiadni és feliratkozni rájuk", + "prefs_reservations_table_everyone_read_only": "Én is közzétehetek és feliratkozhatok, mindenki feliratkozhat", + "prefs_reservations_table_everyone_write_only": "Én is közzétehetek és feliratkozhatok, mindenki közzétehet", + "prefs_reservations_table_everyone_read_write": "Bárki közzétehet és feliratkozhat", + "prefs_reservations_table_not_subscribed": "Nincs feliratkozva", + "prefs_reservations_table_click_to_subscribe": "Kattintson a feliratkozáshoz", + "prefs_reservations_dialog_title_add": "Téma elmentése", + "prefs_reservations_dialog_title_edit": "Foglalt téma szerkesztése", + "prefs_reservations_dialog_title_delete": "Témafoglalás törlése", + "prefs_reservations_dialog_description": "A téma lefoglalásával a téma tulajdonjogát szerezheti meg, és meghatározhatja a többi felhasználó hozzáférési jogosultságait a témához.", + "prefs_reservations_dialog_topic_label": "Téma", + "prefs_reservations_dialog_access_label": "Hozzáférés", + "reservation_delete_dialog_description": "A foglalás törlésével lemondasz a téma feletti tulajdonjogodról, és mások is lefoglalhatják azt. A meglévő üzeneteket és mellékleteket megtarthatod vagy törölheted.", + "reservation_delete_dialog_action_keep_title": "A gyorsítótárban tárolt üzenetek és mellékletek megőrzése", + "reservation_delete_dialog_action_keep_description": "A szerveren gyorsítótárba mentett üzenetek és mellékletek nyilvánosan láthatóvá válnak azok számára, akik ismerik a téma nevét.", + "reservation_delete_dialog_action_delete_title": "A gyorsítótárban tárolt üzenetek és mellékletek törlése", + "reservation_delete_dialog_action_delete_description": "A gyorsítótárban tárolt üzenetek és mellékletek véglegesen törlésre kerülnek. Ez a művelet visszafordíthatatlan.", + "reservation_delete_dialog_submit_button": "Foglalás törlése", + "error_boundary_button_reload_ntfy": "Töltsd be nekem", + "web_push_subscription_expiring_title": "Az értesítések felfüggesztésre kerülnek", + "web_push_subscription_expiring_body": "Nyissa meg az ntfy alkalmazást, hogy továbbra is értesítéseket kapjon", + "web_push_unknown_notification_title": "Ismeretlen értesítés érkezett a szerverről", + "web_push_unknown_notification_body": "Előfordulhat, hogy a webalkalmazás megnyitásával frissítenie kell az ntfy-t" } From d1696ac5b6e9a65e7908748eb925243bad91b5cf Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 8 Jun 2026 21:52:54 -0400 Subject: [PATCH 04/46] Docs: add topic generator --- docs/publish.md | 40 ++++- docs/static/css/topic-generator.css | 235 ++++++++++++++++++++++++++++ docs/static/js/topic-generator.js | 121 ++++++++++++++ mkdocs.yml | 2 + 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 docs/static/css/topic-generator.css create mode 100644 docs/static/js/topic-generator.js diff --git a/docs/publish.md b/docs/publish.md index 9ac600e9..0060cdcc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -1,7 +1,7 @@ # Publishing Publishing messages can be done via HTTP PUT/POST or via the [ntfy CLI](subscribe/cli.md#publish-messages) ([install instructions](install.md)). Topics are created on the fly by subscribing or publishing to them. Because there is no sign-up, **the topic is essentially a password**, so pick -something that's not easily guessable. +something that's not easily guessable (see [picking a topic](#picking-a-topic) for a handy topic name generator). Here's an example showing how to publish a simple message using a POST request: @@ -308,6 +308,44 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
Notification using a click action, a user action, with an external image attachment and forwarded via email
+## Picking a topic +Since there is no sign-up, **the topic is essentially a password**, so pick something that's not easily guessable. Topic names may +only contain letters, numbers, underscores and dashes (`[-_A-Za-z0-9]`), and may be up to 64 characters long. + +Not sure what to pick? Type a name below and the generator will add a random, hard-to-guess suffix for you. Everything happens locally in your browser: + +
+
+Topic name generator + +
+
+
+
+ + +
+
Spaces and characters other than letters, numbers, - and _ are removed automatically as you type. Names are capped at 64 characters.
+
+
+
+Your topic: +
+

+
+
+
+
+Your topic URL: +
+
https://ntfy.sh/
+ +
+
+
+
+
+ ## Message title _Supported on:_ :material-android: :material-apple: :material-firefox: diff --git a/docs/static/css/topic-generator.css b/docs/static/css/topic-generator.css new file mode 100644 index 00000000..5770da22 --- /dev/null +++ b/docs/static/css/topic-generator.css @@ -0,0 +1,235 @@ +/* Topic name generator (Publishing page) */ +/* Styled to mirror the config generator (header + left form / right output panels). */ + +.tg-generator { + margin: 16px 0 24px; + border: 1px solid #ddd; + border-radius: 10px; + background: #fff; + overflow: hidden; + font-size: 0.78rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); +} + +/* Header (matches .cg-modal-header) */ +.tg-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid #ddd; +} + +.tg-title { + font-weight: 600; + font-size: 0.9rem; +} + +.tg-reset { + background: none; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.72rem; + color: #777; + cursor: pointer; + padding: 4px 12px; + font-family: inherit; + transition: color 0.15s, border-color 0.15s; +} + +.tg-reset:hover { + color: #333; + border-color: #999; +} + +/* Body: left (form) + right (output), matches .cg-modal-body */ +.tg-body { + display: flex; + min-height: 0; +} + +.tg-left { + flex: 1; + border-right: 1px solid #ddd; + padding: 16px 18px; + min-width: 0; +} + +.tg-right { + flex: 1; + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +/* One output per block: label on its own line, then value field + copy button */ +.tg-output-row { + display: flex; + flex-direction: column; + min-width: 0; +} + +.tg-output-line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +/* Form field (matches .cg-field) */ +.tg-field > label { + display: block; + font-weight: 500; + margin-bottom: 4px; + font-size: 0.78rem; + color: #555; +} + +.tg-field input[type="text"] { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 0.78rem; + font-family: inherit; + box-sizing: border-box; + background: #fff; +} + +.tg-field input[type="text"]:focus { + border-color: var(--md-primary-fg-color); + outline: none; + box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15); +} + +.tg-note { + margin-top: 10px; + font-size: 0.72rem; + color: #999; + line-height: 1.5; +} + +.tg-note code { + font-size: 0.72rem; + padding: 1px 4px; +} + +.tg-output-label { + margin-bottom: 4px; + white-space: nowrap; + font-weight: 500; + font-size: 0.78rem; + color: #555; +} + +/* Copy button (matches .cg-btn-copy) */ +.tg-btn-copy { + background: none; + color: #777; + border: none; + padding: 2px 4px; + cursor: pointer; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s; +} + +.tg-btn-copy:hover { + color: #333; +} + +/* Output block (matches .cg-output-wrap pre). Scoped under .tg-generator so the margin + reset beats the theme's .md-typeset pre rule, which otherwise adds a stray top margin. */ +.tg-generator .tg-output { + flex: 1; + min-width: 0; + margin: 0; + padding: 6px 9px; + background: #f5f5f5; + color: var(--md-default-fg-color); + border: 1px solid #ddd; + border-radius: 6px; + overflow-x: auto; + font-family: var(--md-code-font-family, monospace); + font-size: 0.72rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: anywhere; +} + +/* Dark mode */ +body[data-md-color-scheme="slate"] .tg-generator { + background: #1e1e2e; + border-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-header { + border-bottom-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-title { + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-reset { + border-color: #555; + color: #888; +} + +body[data-md-color-scheme="slate"] .tg-reset:hover { + border-color: #888; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-left { + border-right-color: #444; +} + +body[data-md-color-scheme="slate"] .tg-field > label, +body[data-md-color-scheme="slate"] .tg-output-label { + color: #aaa; +} + +body[data-md-color-scheme="slate"] .tg-btn-copy { + color: #888; +} + +body[data-md-color-scheme="slate"] .tg-btn-copy:hover { + color: #bbb; +} + +body[data-md-color-scheme="slate"] .tg-field input[type="text"] { + background: #2a2a3a; + border-color: #555; + color: #ddd; +} + +body[data-md-color-scheme="slate"] .tg-note { + color: #777; +} + +body[data-md-color-scheme="slate"] .tg-output { + background: #161620; + border-color: #444; +} + +/* Responsive: stack panels like the config generator does on mobile */ +@media (max-width: 700px) { + .tg-body { + flex-direction: column; + } + + .tg-left { + border-right: none; + border-bottom: 1px solid #ddd; + } + + body[data-md-color-scheme="slate"] .tg-left { + border-bottom-color: #444; + } +} diff --git a/docs/static/js/topic-generator.js b/docs/static/js/topic-generator.js new file mode 100644 index 00000000..a6739891 --- /dev/null +++ b/docs/static/js/topic-generator.js @@ -0,0 +1,121 @@ +// Topic name generator for the ntfy docs +// +// A tiny helper that lives on the "Publishing" page. The user types a memorable +// prefix (e.g. "backups"), and the widget appends a random, hard-to-guess suffix +// (e.g. "backups-x7Kp2mQ9"). The result is a valid, unguessable topic name. +// +// Topic names on the server must match ^[-_A-Za-z0-9]{1,64}$ (see server.go), so as +// the user types we strip anything that isn't allowed (spaces, slashes, punctuation, +// emoji, ...) live and cap the whole thing at 64 characters. The random suffix is +// generated once on load and can be re-rolled with the "Regenerate suffix" button. +(function () { + // Allowed topic characters per the server regex ^[-_A-Za-z0-9]{1,64}$ + const ALLOWED = /[^-_A-Za-z0-9]/g; + const MAX_LEN = 64; + + // Suffix alphabet: full base62 (letters + digits). We deliberately keep look-alikes + // (0/O, l/1) for maximum entropy -- this is a generated suffix, not something typed by + // hand. Hyphen/underscore are excluded so the "-" separator stays visually clear. + const SUFFIX_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const SUFFIX_LEN = 10; + + // randomSuffix returns a cryptographically random string from SUFFIX_ALPHABET. + // It uses rejection sampling to avoid the modulo bias that a plain `byte % 62` would + // introduce (256 is not a multiple of 62), keeping every character equally likely. + function randomSuffix() { + const n = SUFFIX_ALPHABET.length; + const limit = Math.floor(256 / n) * n; // largest multiple of n that fits in a byte + const buf = new Uint8Array(1); + let out = ""; + while (out.length < SUFFIX_LEN) { + crypto.getRandomValues(buf); + if (buf[0] < limit) { + out += SUFFIX_ALPHABET[buf[0] % n]; + } + } + return out; + } + + // sanitize strips everything that isn't a valid topic character. + function sanitize(value) { + return value.replace(ALLOWED, ""); + } + + function initTopicGenerator() { + const root = document.getElementById("tg-widget"); + if (!root) return; + + const input = root.querySelector("#tg-input"); + const outputName = root.querySelector("#tg-output-name"); + const outputUrl = root.querySelector("#tg-output-url"); + const reroll = root.querySelector("#tg-reroll"); + + let suffix = randomSuffix(); + + // update recomputes the live preview from the (sanitized) input + current suffix. + function update() { + // Sanitize in place so the user sees disallowed characters disappear as they type. + const cleaned = sanitize(input.value); + if (cleaned !== input.value) { + const pos = input.selectionStart - (input.value.length - cleaned.length); + // Reassigning .value and setSelectionRange make the browser scroll the field into + // view (there is no preventScroll option for setSelectionRange), which jumps the + // whole page. Capture the scroll position and restore it afterwards. + const scrollX = window.scrollX; + const scrollY = window.scrollY; + input.value = cleaned; + // Best-effort caret restore so removing a bad char doesn't jump the cursor to the end. + try { input.setSelectionRange(pos, pos); } catch { /* ignore */ } + window.scrollTo(scrollX, scrollY); + } + + // Compose "-", capped at the 64-char topic limit. With no prefix, + // fall back to just the random suffix so the output is always a valid topic. + let topic; + if (cleaned === "") { + topic = suffix; + } else { + const maxPrefix = MAX_LEN - suffix.length - 1; // room for "-" + suffix + const prefix = cleaned.slice(0, Math.max(0, maxPrefix)); + topic = prefix === "" ? suffix : prefix + "-" + suffix; + } + + outputName.textContent = topic; + outputUrl.textContent = "https://ntfy.sh/" + topic; + } + + input.addEventListener("input", update); + reroll.addEventListener("click", function () { + suffix = randomSuffix(); + update(); + input.focus(); + }); + + // Copy buttons: copy the target output and briefly swap the clipboard icon for a checkmark, + // mirroring the config generator's copy button behavior. + const copyIcon = ""; + const checkIcon = ""; + root.querySelectorAll(".tg-btn-copy").forEach(function (btn) { + btn.addEventListener("click", function () { + const target = root.querySelector("#" + btn.dataset.copy); + if (!target || !target.textContent) return; + navigator.clipboard.writeText(target.textContent).then(function () { + btn.innerHTML = checkIcon; + btn.style.color = "var(--md-primary-fg-color)"; + setTimeout(function () { + btn.innerHTML = copyIcon; + btn.style.color = ""; + }, 2000); + }); + }); + }); + + update(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initTopicGenerator); + } else { + initTopicGenerator(); + } +})(); diff --git a/mkdocs.yml b/mkdocs.yml index 76b5c1f9..5bb53731 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,9 +44,11 @@ extra_javascript: - static/js/extra.js - static/js/bcrypt.js - static/js/config-generator.js + - static/js/topic-generator.js extra_css: - static/css/extra.css - static/css/config-generator.css + - static/css/topic-generator.css markdown_extensions: - admonition From 2d7faac0eaa8d7afb77628c0024f95da8683efc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:44:37 +0000 Subject: [PATCH 05/46] Bump actions/checkout from 6.0.2 to 6.0.3 in the all group Bumps the all group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 2 +- .github/workflows/docs.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- .github/workflows/test.yaml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5453fb3a..333ec3a0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index da1e59ea..28af9f29 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -9,10 +9,10 @@ jobs: steps: - name: Checkout ntfy code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Checkout docs pages code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: binwiederhier/ntfy-docs.github.io path: build/ntfy-docs.github.io diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index db8f15f5..6eee546b 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -25,7 +25,7 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ead55ba6..c89d32bf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -25,7 +25,7 @@ jobs: NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Install Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: From a0775701c16427d4bf290e2c1fae86936856119f Mon Sep 17 00:00:00 2001 From: Vincent Vu <172068404+rubixvi@users.noreply.github.com> Date: Thu, 11 Jun 2026 12:33:33 +1000 Subject: [PATCH 06/46] chores(docs): addition of WordPress ntfy integration Add WordPress Rubix Notify - ntfy integration plugin. --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index 2c21115e..57e0a85e 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -189,6 +189,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS) - [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python) - [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services +- [Rubix Notify](https://wordpress.org/plugins/rubix-notify) - WordPress Integration with ntfy (PHP + React). ## Blog + forum posts From fd716e4807c1d3d1bbeb162c37e981a83bc28a25 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 11:07:48 -0400 Subject: [PATCH 07/46] Phase 1 --- server/config.go | 2 +- user/magic_link_test.go | 269 ++++++++++++++++++++++++++++++++ user/manager.go | 208 +++++++++++++++++++++++- user/manager_postgres.go | 32 +++- user/manager_postgres_schema.go | 44 +++++- user/manager_sqlite.go | 32 +++- user/manager_sqlite_schema.go | 50 +++++- user/manager_test.go | 77 +++++++++ user/types.go | 43 ++++- user/util.go | 22 +++ util/util.go | 36 +++-- util/util_test.go | 24 +++ 12 files changed, 816 insertions(+), 23 deletions(-) create mode 100644 user/magic_link_test.go diff --git a/server/config.go b/server/config.go index b7dadddf..de94b4ca 100644 --- a/server/config.go +++ b/server/config.go @@ -71,7 +71,7 @@ const ( DefaultVisitorEmailLimitReplenish = time.Hour DefaultVisitorTopicCreationLimitBurst = 100 DefaultVisitorTopicCreationLimitReplenish = time.Minute - DefaultVisitorAccountCreationLimitBurst = 3 + DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket) DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 DefaultVisitorAuthFailureLimitReplenish = time.Minute diff --git a/user/magic_link_test.go b/user/magic_link_test.go new file mode 100644 index 00000000..4be1fe55 --- /dev/null +++ b/user/magic_link_test.go @@ -0,0 +1,269 @@ +package user + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// addVerifyLink generates a raw token, stores an email-verification magic link for it, and +// returns the raw token so the test can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, expires int64) string { + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindEmailVerify, + UserID: userID, + Email: email, + Expires: expires, + Created: time.Now().Unix(), + })) + return raw +} + +func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + + // Before verifying: pending, not yet verified, no primary + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Verify: the first verified email auto-becomes primary + m, err := a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + + emails, err = a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, emails) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "phil@example.com", primary) + pending, err = a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // Reset-by-email lookup resolves to the account + userID, err := a.UserIDByPrimaryEmail("phil@example.com") + require.Nil(t, err) + require.Equal(t, phil.ID, userID) + }) +} + +func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw1)) + require.Nil(t, err) + + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw2)) + require.Nil(t, err) + + // Both verified, but primary is still the first + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "first@example.com", primary) + }) +} + +func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + + // phil verifies shared@ first -> becomes his primary + rawPhil := addVerifyLink(t, a, phil.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(rawPhil)) + require.Nil(t, err) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + + // ben verifies the same address -> allowed as secondary, but NOT his primary + rawBen := addVerifyLink(t, a, ben.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(rawBen)) + require.Nil(t, err) + emails, err := a.Emails(ben.ID) + require.Nil(t, err) + require.Equal(t, []string{"shared@example.com"}, emails) + primary, err = a.PrimaryEmail(ben.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Explicitly promoting ben's copy to primary collides with phil's + require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear, which was a no-op anyway) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + }) +} + +func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) + }) +} + +func TestUser_MagicLink_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(-time.Minute).Unix()) + _, err = a.VerifyEmail(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Nothing got verified + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + }) +} + +func TestUser_MagicLink_SingleUse(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + _, err = a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + // Second click: token already consumed + _, err = a.VerifyEmail(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + + // Only one pending row remains; the old token no longer works + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + _, err = a.MagicLinkByHash(hashToken(raw1)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + m, err := a.MagicLinkByHash(hashToken(raw2)) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + }) +} + +func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindPasswordReset, + UserID: phil.ID, + Expires: time.Now().Add(time.Hour).Unix(), + Created: time.Now().Unix(), + })) + + m, err := a.MagicLinkByHash(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, MagicLinkKindPasswordReset, m.Kind) + require.Equal(t, phil.ID, m.UserID) + require.Equal(t, "", m.Email) // reset rows carry no email + + // Reset rows do not appear as pending emails + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // New request replaces the old token + raw2 := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw2), + Kind: MagicLinkKindPasswordReset, + UserID: phil.ID, + Expires: time.Now().Add(time.Hour).Unix(), + Created: time.Now().Unix(), + })) + _, err = a.MagicLinkByHash(hashToken(raw)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Single use: deleting consumes it + require.Nil(t, a.DeleteMagicLink(hashToken(raw2))) + _, err = a.MagicLinkByHash(hashToken(raw2)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_Reaper(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", time.Now().Add(-time.Hour).Unix()) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Now().Add(time.Hour).Unix()) + + require.Nil(t, a.deleteExpiredMagicLinks()) + + _, err = a.MagicLinkByHash(hashToken(expired)) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + m, err := a.MagicLinkByHash(hashToken(valid)) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + _, err := a.UserIDByPrimaryEmail("ghost@example.com") + require.ErrorIs(t, err, ErrUserNotFound) + }) +} diff --git a/user/manager.go b/user/manager.go index 34d96403..e6df6910 100644 --- a/user/manager.go +++ b/user/manager.go @@ -40,6 +40,7 @@ const ( DefaultUserPasswordBcryptCost = 10 DefaultAccessCacheEnabled = false DefaultAccessCacheReloadInterval = 87 * time.Second + DefaultExpiredMagicLinkReapInterval = time.Hour // How often expired email-verify/password-reset links are swept ) var ( @@ -91,6 +92,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval) } go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval) + go manager.asyncExpiredMagicLinkReapLoop(DefaultExpiredMagicLinkReapInterval) return manager, nil } @@ -128,6 +130,25 @@ func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) { } } +// asyncExpiredMagicLinkReapLoop periodically deletes expired email-verification and +// password-reset links so the user_magic_link table does not accumulate dead rows. Expiry is +// already enforced on read, so this is housekeeping only; it replaces the old in-memory +// expireLoop that lived in mail.Sender. +func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-a.quit: + return + case <-ticker.C: + if err := a.deleteExpiredMagicLinks(); err != nil { + log.Tag(tag).Err(err).Warn("Reaping expired magic links failed") + } + } + } +} + // Authenticate checks username and password and returns a User if correct, and the user has not been // marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // the password is correct or incorrect. @@ -1451,12 +1472,197 @@ func (a *Manager) AddEmail(userID, email string) error { return nil } -// RemoveEmail deletes a verified email address from the user with the given user ID +// RemoveEmail deletes a verified email address from the user with the given user ID. +// Removing the primary email leaves the account with no primary -- there is deliberately +// no auto-promotion of another verified address; the user is nudged to pick a new one. func (a *Manager) RemoveEmail(userID, email string) error { _, err := a.db.Exec(a.queries.deleteEmail, userID, email) return err } +// PrimaryEmail returns the user's primary (recovery) email address, or an empty string if +// the user has not designated one. +func (a *Manager) PrimaryEmail(userID string) (string, error) { + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectPrimaryEmail, userID).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } else if err != nil { + return "", err + } + return email.String, nil +} + +// UserIDByPrimaryEmail returns the ID of the (at most one) account for which the given address +// is the primary email. Returns ErrUserNotFound if no account claims it as primary. Used by the +// password-reset request flow to resolve an email identifier to a single account. +func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) { + var userID string + err := a.db.ReadOnly().QueryRow(a.queries.selectUserIDByPrimary, email).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrUserNotFound + } else if err != nil { + return "", err + } + return userID, nil +} + +// PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with +// an outstanding email-verification magic link. +func (a *Manager) PendingEmails(userID string) ([]string, error) { + rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, userID) + if err != nil { + return nil, err + } + defer rows.Close() + emails := make([]string, 0) + for rows.Next() { + var email string + if err := rows.Scan(&email); err != nil { + return nil, err + } + emails = append(emails, email) + } + if err := rows.Err(); err != nil { + return nil, err + } + return emails, nil +} + +// SetPrimaryEmail marks a verified email address as the user's primary (recovery) email, +// clearing any previous primary in the same transaction. Returns ErrEmailNotFound if the +// address is not verified on the account, or ErrEmailPrimaryElsewhere if it is already the +// primary email on another account (enforced by the global partial unique index). +func (a *Manager) SetPrimaryEmail(userID, email string) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if _, err := tx.Exec(a.queries.updateEmailClearPrimary, userID); err != nil { + return err + } + res, err := tx.Exec(a.queries.updateEmailSetPrimary, userID, email) + if err != nil { + if isUniqueConstraintError(err) { + return ErrEmailPrimaryElsewhere + } + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrEmailNotFound // Address not verified on this account + } + return nil + }) +} + +// AddMagicLink stores a pending magic link, replacing any existing link in the same scope: +// for email_verify that is the (user_id, email) pair (one pending verification per address); +// for password_reset that is the user_id (one active reset per account). The replace-delete and +// the insert run in one transaction so a re-request atomically supersedes the old token. +func (a *Manager) AddMagicLink(m *MagicLink) error { + return db.ExecTx(a.db, func(tx *sql.Tx) error { + switch m.Kind { + case MagicLinkKindEmailVerify: + if _, err := tx.Exec(a.queries.deleteVerifyScope, m.UserID, m.Email); err != nil { + return err + } + case MagicLinkKindPasswordReset: + if _, err := tx.Exec(a.queries.deleteResetScope, m.UserID); err != nil { + return err + } + default: + return ErrInvalidArgument + } + if _, err := tx.Exec(a.queries.insertMagicLink, m.TokenHash, string(m.Kind), m.UserID, nullString(m.Email), m.Expires, m.Created); err != nil { + return err + } + return nil + }) +} + +// MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning +// ErrMagicLinkNotFound if none exists. Callers must assert the returned Kind matches the flow +// they serve and check Expires themselves. +func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) { + var m MagicLink + var kind string + var email sql.NullString + err := a.db.ReadOnly().QueryRow(a.queries.selectMagicLinkByHash, tokenHash).Scan(&m.TokenHash, &kind, &m.UserID, &email, &m.Expires, &m.Created) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrMagicLinkNotFound + } else if err != nil { + return nil, err + } + m.Kind = MagicLinkKind(kind) + m.Email = email.String + return &m, nil +} + +// DeleteMagicLink deletes a magic link by its token hash. Used to enforce single use after a +// reset is performed (email verification deletes the row inside VerifyEmail's transaction). +func (a *Manager) DeleteMagicLink(tokenHash string) error { + _, err := a.db.Exec(a.queries.deleteMagicLinkByHash, tokenHash) + return err +} + +// VerifyEmail consumes an email-verification magic link: after validating the token (kind + +// expiry), it deletes the link, adds the address to the user's verified emails, and -- if the +// user has no primary email yet and the address is not already primary on another account -- +// promotes the new address to primary. All mutations run in one transaction. A primary +// collision simply leaves the address verified but non-primary. Returns the consumed link. +func (a *Manager) VerifyEmail(tokenHash string) (*MagicLink, error) { + m, err := a.MagicLinkByHash(tokenHash) + if err != nil { + return nil, err + } + if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires { + return nil, ErrMagicLinkNotFound + } + err = db.ExecTx(a.db, func(tx *sql.Tx) error { + // Single use: delete the link, then add the (idempotent) verified address + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { + return err + } + // Promote to primary only if the user has none yet and the address is globally free. + // We check with SELECTs rather than catching a unique violation, because Postgres aborts + // the whole transaction on any constraint error (which would undo the verified-email add). + var primary sql.NullString + err := tx.QueryRow(a.queries.selectPrimaryEmail, m.UserID).Scan(&primary) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if primary.String != "" { + return nil // User already has a primary -- leave it + } + var owner string + err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil { + return err + } + } else if err != nil { + return err + } + // owner found -> address is primary elsewhere -> stays a verified secondary + return nil + }) + if err != nil { + return nil, err + } + return m, nil +} + +// deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced +// on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop. +func (a *Manager) deleteExpiredMagicLinks() error { + _, err := a.db.Exec(a.queries.deleteExpiredMagicLinks, time.Now().Unix()) + return err +} + func (a *Manager) readEmail(rows *sql.Rows) (string, error) { var email string if !rows.Next() { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 0395baae..9cedf5fd 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -217,9 +217,23 @@ const ( postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` // Email queries - postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` - postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` - postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email` + postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)` + postgresInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2) ON CONFLICT (user_id, email) DO NOTHING` + postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2` + postgresSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = $1 AND is_primary` + postgresSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = $1 AND is_primary` + postgresUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = TRUE WHERE user_id = $1 AND email = $2` + postgresUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = FALSE WHERE user_id = $1 AND is_primary` + + // Magic link queries (email verification + password reset) + postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)` + postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1` + postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1` + postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 AND email = $2` + postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = $1` + postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 ORDER BY email` + postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1` // Billing queries postgresUpdateBillingQuery = ` @@ -306,7 +320,19 @@ var postgresQueries = queries{ deletePhoneNumber: postgresDeletePhoneNumberQuery, selectEmails: postgresSelectEmailsQuery, insertEmail: postgresInsertEmailQuery, + insertEmailIgnore: postgresInsertEmailIgnoreQuery, deleteEmail: postgresDeleteEmailQuery, + selectPrimaryEmail: postgresSelectPrimaryEmailQuery, + selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, + insertMagicLink: postgresInsertMagicLinkQuery, + selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, + deleteVerifyScope: postgresDeleteVerifyScopeQuery, + deleteResetScope: postgresDeleteResetScopeQuery, + selectPendingEmails: postgresSelectPendingEmailsQuery, + deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, updateBilling: postgresUpdateBillingQuery, } diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index ba8502f2..a855dc66 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -75,8 +75,21 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, email TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (user_id, email) ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schema_version ( store TEXT PRIMARY KEY, version INT NOT NULL @@ -89,7 +102,7 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 7 + postgresCurrentSchemaVersion = 8 postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` ) @@ -102,11 +115,30 @@ const ( PRIMARY KEY (user_id, email) ); ` + + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary. + postgresMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT FALSE; + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + email TEXT, + expires BIGINT NOT NULL, + created BIGINT NOT NULL, + PRIMARY KEY (token_hash) + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'` ) var postgresMigrations = map[int]func(db *sql.DB) error{ 6: postgresMigrateFrom6, + 7: postgresMigrateFrom7, } func setupPostgres(db *sql.DB) error { @@ -141,6 +173,16 @@ func postgresMigrateFrom6(db *sql.DB) error { return nil } +func postgresMigrateFrom7(db *sql.DB) error { + if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil +} + func setupNewPostgres(db *sql.DB) error { if _, err := db.Exec(postgresCreateTablesQueries); err != nil { return err diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 62652ce3..61bafc94 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -214,9 +214,23 @@ const ( sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Email queries - sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` - sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` - sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email` + sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` + sqliteInsertEmailIgnoreQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?) ON CONFLICT (user_id, email) DO NOTHING` + sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + sqliteSelectPrimaryEmailQuery = `SELECT email FROM user_email WHERE user_id = ? AND is_primary = 1` + sqliteSelectUserIDByPrimaryQuery = `SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1` + sqliteUpdateEmailSetPrimaryQuery = `UPDATE user_email SET is_primary = 1 WHERE user_id = ? AND email = ?` + sqliteUpdateEmailClearPrimaryQuery = `UPDATE user_email SET is_primary = 0 WHERE user_id = ? AND is_primary = 1` + + // Magic link queries (email verification + password reset) + sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)` + sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?` + sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? AND email = ?` + sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = ?` + sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? ORDER BY email` + sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?` // Billing queries sqliteUpdateBillingQuery = ` @@ -302,7 +316,19 @@ var sqliteQueries = queries{ deletePhoneNumber: sqliteDeletePhoneNumberQuery, selectEmails: sqliteSelectEmailsQuery, insertEmail: sqliteInsertEmailQuery, + insertEmailIgnore: sqliteInsertEmailIgnoreQuery, deleteEmail: sqliteDeleteEmailQuery, + selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, + selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, + insertMagicLink: sqliteInsertMagicLinkQuery, + selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, + deleteVerifyScope: sqliteDeleteVerifyScopeQuery, + deleteResetScope: sqliteDeleteResetScopeQuery, + selectPendingEmails: sqliteSelectPendingEmailsQuery, + deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, updateBilling: sqliteUpdateBillingQuery, } diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 6ee24f8c..5b389627 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -88,9 +88,23 @@ const ( CREATE TABLE IF NOT EXISTS user_email ( user_id TEXT NOT NULL, email TEXT NOT NULL, + is_primary INT NOT NULL DEFAULT (0), PRIMARY KEY (user_id, email), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -107,7 +121,7 @@ const ( // Schema version table management for SQLite const ( - sqliteCurrentSchemaVersion = 7 + sqliteCurrentSchemaVersion = 8 sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -236,6 +250,26 @@ const ( ); ` + // 7 -> 8: primary (recovery) email + magic-link table for verification/reset. + // No backfill -- existing verified emails stay non-primary, so the ALTER cannot + // conflict and no old notification address becomes a recovery channel. + sqliteMigrate7To8UpdateQueries = ` + ALTER TABLE user_email ADD COLUMN is_primary INT NOT NULL DEFAULT (0); + CREATE UNIQUE INDEX idx_user_email_primary_user ON user_email (user_id) WHERE is_primary = 1; + CREATE UNIQUE INDEX idx_user_email_primary_addr ON user_email (email) WHERE is_primary = 1; + CREATE TABLE IF NOT EXISTS user_magic_link ( + token_hash TEXT NOT NULL, + kind TEXT NOT NULL, + user_id TEXT NOT NULL, + email TEXT, + expires INT NOT NULL, + created INT NOT NULL, + PRIMARY KEY (token_hash), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); + ` + // 5 -> 6 sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; @@ -339,6 +373,7 @@ var ( 4: sqliteMigrateFrom4, 5: sqliteMigrateFrom5, 6: sqliteMigrateFrom6, + 7: sqliteMigrateFrom7, } ) @@ -493,3 +528,16 @@ func sqliteMigrateFrom6(sqlDB *sql.DB) error { return nil }) } + +func sqliteMigrateFrom7(sqlDB *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 7 to 8") + return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil { + return err + } + return nil + }) +} diff --git a/user/manager_test.go b/user/manager_test.go index 6d13929e..3845752e 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1789,6 +1789,83 @@ func TestMigrationFrom4(t *testing.T) { require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters } +func TestMigrationFrom7(t *testing.T) { + filename := filepath.Join(t.TempDir(), "user.db") + rawDB, err := sql.Open("sqlite3", filename) + require.Nil(t, err) + + // Create a "version 7" schema: user_email exists but has no is_primary column, and there + // is no user_magic_link table yet. (Mirrors the production schema right before v8.) + _, err = rawDB.Exec(` + BEGIN; + CREATE TABLE IF NOT EXISTS tier ( + id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, + messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, + calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, attachment_expiry_duration INT NOT NULL, + attachment_bandwidth_limit INT NOT NULL, stripe_monthly_price_id TEXT, stripe_yearly_price_id TEXT + ); + 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) + ); + CREATE UNIQUE INDEX idx_user ON user (user); + CREATE TABLE IF NOT EXISTS user_email ( + user_id TEXT NOT NULL, + email TEXT NOT NULL, + PRIMARY KEY (user_id, email), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL); + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES ('u_everyone', '*', '', 'anonymous', '', 0, UNIXEPOCH()); + INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) + VALUES ('u_phil', 'phil', '', 'user', 'st_phil', 0, UNIXEPOCH()); + INSERT INTO user_email (user_id, email) VALUES ('u_phil', 'old@example.com'); + INSERT INTO schemaVersion (id, version) VALUES (1, 7); + COMMIT; + `) + require.Nil(t, err) + require.Nil(t, rawDB.Close()) + + // Opening the manager triggers the 7 -> 8 migration + a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) + checkSchemaVersion(t, testDB(a)) + + // The pre-existing verified email survives and stays NON-primary (no backfill) + emails, err := a.Emails("u_phil") + require.Nil(t, err) + require.Equal(t, []string{"old@example.com"}, emails) + primary, err := a.PrimaryEmail("u_phil") + require.Nil(t, err) + require.Equal(t, "", primary) + + // The new magic-link machinery works post-migration + raw := generateLinkToken() + require.Nil(t, a.AddMagicLink(&MagicLink{ + TokenHash: hashToken(raw), + Kind: MagicLinkKindEmailVerify, + UserID: "u_phil", + Email: "new@example.com", + Expires: time.Now().Add(24 * time.Hour).Unix(), + Created: time.Now().Unix(), + })) + m, err := a.VerifyEmail(hashToken(raw)) + require.Nil(t, err) + require.Equal(t, "new@example.com", m.Email) + + // new@ becomes primary because the user had none (old@ was a pre-existing non-primary) + primary, err = a.PrimaryEmail("u_phil") + require.Nil(t, err) + require.Equal(t, "new@example.com", primary) +} + func checkSchemaVersion(t *testing.T, d *db.DB) { rows, err := d.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) diff --git a/user/types.go b/user/types.go index c400e48f..44aecd5d 100644 --- a/user/types.go +++ b/user/types.go @@ -73,6 +73,27 @@ type TokenUpdate struct { LastOrigin netip.Addr } +// MagicLinkKind discriminates the two link-token flows stored in the user_magic_link table. +type MagicLinkKind string + +// Magic link kinds +const ( + MagicLinkKindEmailVerify MagicLinkKind = "email_verify" + MagicLinkKindPasswordReset MagicLinkKind = "password_reset" +) + +// MagicLink is a pending, single-use link token -- either an email verification or a +// password reset, distinguished by Kind. The raw token travels in the emailed link; +// only its TokenHash (hex SHA-256) is persisted. +type MagicLink struct { + TokenHash string + Kind MagicLinkKind + UserID string + Email string // Address being verified for email_verify; empty (NULL) for password_reset + Expires int64 + Created int64 +} + // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` @@ -275,6 +296,8 @@ var ( ErrPhoneNumberExists = errors.New("phone number already exists") ErrEmailNotFound = errors.New("email not found") ErrEmailExists = errors.New("email already exists") + ErrEmailPrimaryElsewhere = errors.New("email is the recovery email on another account") + ErrMagicLinkNotFound = errors.New("magic link not found") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) @@ -350,9 +373,23 @@ type queries struct { deletePhoneNumber string // Email queries - selectEmails string - insertEmail string - deleteEmail string + selectEmails string + insertEmail string + insertEmailIgnore string // Idempotent insert (ON CONFLICT DO NOTHING) used inside VerifyEmail + deleteEmail string + selectPrimaryEmail string + selectUserIDByPrimary string + updateEmailSetPrimary string + updateEmailClearPrimary string + + // Magic link queries (email verification + password reset) + insertMagicLink string + selectMagicLinkByHash string + deleteMagicLinkByHash string + deleteVerifyScope string // Delete pending email_verify rows for (user_id, email) + deleteResetScope string // Delete the active password_reset row for user_id + selectPendingEmails string // Pending (unverified) email addresses for a user + deleteExpiredMagicLinks string // Billing queries updateBilling string diff --git a/user/util.go b/user/util.go index 16f6cc09..5157b95f 100644 --- a/user/util.go +++ b/user/util.go @@ -1,7 +1,9 @@ package user import ( + "crypto/sha256" "database/sql" + "encoding/hex" "regexp" "strings" @@ -9,6 +11,11 @@ import ( "heckel.io/ntfy/v2/util" ) +// linkTokenLength is the length of a raw magic-link token. At 48 base62 characters +// it carries ~285 bits of entropy, well above the ~256-bit target, so the tokens +// need no brute-force cap -- just expiry and single-use. +const linkTokenLength = 48 + var ( allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*) allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' @@ -67,6 +74,21 @@ func GenerateToken() string { return util.RandomLowerStringPrefix(tokenPrefix, tokenLength) } +// generateLinkToken returns a fresh high-entropy raw token for a magic link +// (email verification or password reset). The raw token is carried in the emailed +// link; only its hashToken digest is persisted. +func generateLinkToken() string { + return util.RandomString(linkTokenLength) +} + +// hashToken returns the hex-encoded SHA-256 digest of a raw magic-link token. +// Tokens are stored hashed so a database read cannot yield working links; a high-entropy +// token makes a fast (unsalted) hash sufficient, unlike a password. +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + // HashPassword hashes the given password using bcrypt with the configured cost func HashPassword(password string) (string, error) { return hashPassword(password, DefaultUserPasswordBcryptCost) diff --git a/util/util.go b/util/util.go index be349691..1c924e2b 100644 --- a/util/util.go +++ b/util/util.go @@ -2,20 +2,19 @@ package util import ( "bytes" + crand "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "math" - "math/rand" "net/netip" "os" "regexp" "slices" "strconv" "strings" - "sync" "time" "unicode/utf8" @@ -30,8 +29,6 @@ const ( ) var ( - random = rand.New(rand.NewSource(time.Now().UnixNano())) - randomMutex = sync.Mutex{} sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) errInvalidPriority = errors.New("invalid priority") noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`) @@ -144,14 +141,33 @@ func RandomLowerStringPrefix(prefix string, length int) string { return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset) } +// randomStringPrefixWithCharset builds a random string from charset using crypto/rand. +// We use rejection sampling (dropping the few highest byte values that would skew the +// distribution) so every character is uniformly distributed -- important because these +// strings back security tokens (access tokens, magic-link tokens, IDs), not just labels. func randomStringPrefixWithCharset(prefix string, length int, charset string) string { - randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?! - defer randomMutex.Unlock() - b := make([]byte, length-len(prefix)) - for i := range b { - b[i] = charset[random.Intn(len(charset))] + n := length - len(prefix) + if n <= 0 { + return prefix[:length] } - return prefix + string(b) + result := make([]byte, n) + limit := 256 - (256 % len(charset)) // reject byte values >= limit to avoid modulo bias + buf := make([]byte, n) + for i := 0; i < n; { + if _, err := crand.Read(buf); err != nil { + panic("crypto/rand failed: " + err.Error()) // Should never happen on a sane system + } + for _, c := range buf { + if i >= n { + break + } + if int(c) < limit { + result[i] = charset[int(c)%len(charset)] + i++ + } + } + } + return prefix + string(result) } // ValidRandomString returns true if the given string matches the format created by RandomString diff --git a/util/util_test.go b/util/util_test.go index ae855147..8de18b66 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -25,6 +25,30 @@ func TestRandomString(t *testing.T) { require.NotEqual(t, s1, s2) } +// TestRandomString_CSPRNG guards the crypto/rand-backed generator: every character must come +// from the expected charset (rejection sampling correctness) and a large batch must be unique +// (no clock-seeded PRNG collapsing to a predictable stream). +func TestRandomString_CSPRNG(t *testing.T) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + seen := make(map[string]bool) + charCounts := make(map[rune]int) + for i := 0; i < 5000; i++ { + s := RandomString(48) + require.Equal(t, 48, len(s)) + require.False(t, seen[s], "duplicate random string generated") + seen[s] = true + for _, c := range s { + require.Contains(t, charset, string(c)) + charCounts[c]++ + } + } + // Every charset character should appear at least once across 5000*48 draws; a heavily + // biased or broken generator would leave gaps. + for _, c := range charset { + require.Greater(t, charCounts[c], 0, "character %q never appeared", string(c)) + } +} + func TestFileExists(t *testing.T) { filename := filepath.Join(t.TempDir(), "somefile.txt") require.Nil(t, os.WriteFile(filename, []byte{0x25, 0x86}, 0600)) From 44dac47d76156e2247399400ef79bf2378b92c73 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 11:12:26 -0400 Subject: [PATCH 08/46] Phsae 2 fix tesPhsae 2 fix testt --- server/server_account_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/server_account_test.go b/server/server_account_test.go index 9aea7d4d..f2f36168 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -78,7 +78,8 @@ func TestAccount_Signup_LimitReached(t *testing.T) { s := newTestServer(t, conf) defer s.closeDatabases() - for i := 0; i < 3; i++ { + // Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests) + for i := 0; i < 6; i++ { rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) require.Equal(t, 200, rr.Code) } @@ -131,7 +132,8 @@ func TestAccount_Signup_Rate_Limit(t *testing.T) { conf.EnableSignup = true s := newTestServer(t, conf) - for i := 0; i < 3; i++ { + // Burst is DefaultVisitorAccountCreationLimitBurst (shared with password-reset requests) + for i := 0; i < 6; i++ { rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) require.Equal(t, 200, rr.Code, "failed on iteration %d", i) } From 30dd4840a21862338c40720226c493941d4cb4e5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 11:40:59 -0400 Subject: [PATCH 09/46] Phase 2, email verification rework, ui stuff --- mail/sender.go | 93 ++++-------- server/errors.go | 3 +- server/server.go | 71 ++++++--- server/server_account.go | 163 +++++++++++++++++---- server/server_account_email_test.go | 190 ++++++++++++++++++++++++ server/server_test.go | 8 +- server/smtp_sender.go | 8 + server/types.go | 44 +++--- user/magic_link_test.go | 91 +++++------- user/manager.go | 52 ++++++- user/manager_test.go | 13 +- web/public/static/langs/en.json | 29 +++- web/src/app/AccountApi.js | 46 +++++- web/src/app/errors.js | 12 +- web/src/app/utils.js | 2 + web/src/components/Account.jsx | 219 +++++++++++++++------------- web/src/components/App.jsx | 2 + web/src/components/EmailVerify.jsx | 74 ++++++++++ web/src/components/routes.js | 1 + 19 files changed, 789 insertions(+), 332 deletions(-) create mode 100644 server/server_account_email_test.go create mode 100644 web/src/components/EmailVerify.jsx diff --git a/mail/sender.go b/mail/sender.go index 5511cb04..b840adf1 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -6,17 +6,14 @@ import ( "net" "net/smtp" "strings" - "sync" "time" "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/util" ) const ( - verifyCodeExpiry = 10 * time.Minute - verifyCodeLength = 6 - verifyCodeSubject = "ntfy email verification" + emailVerificationSubject = "Verify your email for ntfy" + passwordResetSubject = "Reset your ntfy password" ) // Config holds the SMTP configuration for the mail sender @@ -27,33 +24,22 @@ type Config struct { From string // Sender email address } -// Sender sends emails and manages email verification codes +// Sender sends emails via SMTP, including the magic-link emails for email verification and +// password reset. Pending verification/reset state lives in the database (see user.Manager), +// not in this struct. type Sender struct { - config *Config - codes map[string]verifyCode // Verification codes, keyed by email - mu sync.Mutex - closeChan chan struct{} -} - -type verifyCode struct { - code string - expires time.Time + config *Config } // NewSender creates a new mail Sender with the given SMTP config func NewSender(config *Config) *Sender { - s := &Sender{ - config: config, - codes: make(map[string]verifyCode), - closeChan: make(chan struct{}), - } - go s.expireLoop() - return s + return &Sender{config: config} } -// Close stops the background expiry loop +// Close is a no-op, kept so callers don't need to special-case the sender. The sender holds +// no background goroutines (magic-link expiry is swept by the user.Manager reaper). func (s *Sender) Close() { - close(s.closeChan) + // Nothing to do } // Addr returns the SMTP server address @@ -104,51 +90,24 @@ Content-Type: text/plain; charset="utf-8" return s.SendRaw(to, []byte(message)) } -// SendVerification generates a random code, stores it in-memory, and sends a verification email -func (s *Sender) SendVerification(to string) error { - code := util.RandomString(verifyCodeLength) - s.mu.Lock() - s.codes[to] = verifyCode{ - code: code, - expires: time.Now().Add(verifyCodeExpiry), - } - s.mu.Unlock() - body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code) - return s.Send(to, verifyCodeSubject, body) +// SendEmailVerification sends an email containing a magic link to verify ownership of the +// recipient address. The link carries a one-time token validated against the database. +func (s *Sender) SendEmailVerification(to, link string) error { + body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: + +%s + +This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) + return s.Send(to, emailVerificationSubject, body) } -// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success. -func (s *Sender) CheckVerification(email, code string) bool { - s.mu.Lock() - defer s.mu.Unlock() - vc, ok := s.codes[email] - if !ok || time.Now().After(vc.expires) || vc.code != code { - return false - } - delete(s.codes, email) - return true -} +// SendPasswordReset sends an email containing a magic link to set a new password. The link +// carries a one-time token validated against the database. +func (s *Sender) SendPasswordReset(to, link string) error { + body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: -func (s *Sender) expireLoop() { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - for { - select { - case <-ticker.C: - s.expireVerificationCodes() - case <-s.closeChan: - return - } - } -} +%s -func (s *Sender) expireVerificationCodes() { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - for email, vc := range s.codes { - if now.After(vc.expires) { - delete(s.codes, email) - } - } +This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) + return s.Send(to, passwordResetSubject, body) } diff --git a/server/errors.go b/server/errors.go index 3197d6ca..5ac6ce38 100644 --- a/server/errors.go +++ b/server/errors.go @@ -143,7 +143,7 @@ var ( errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code invalid or expired", "", nil} + errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} @@ -156,6 +156,7 @@ var ( errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil} errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil} errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} + errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the recovery email on another account", "", nil} errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} diff --git a/server/server.go b/server/server.go index c380bd26..95d2ecf7 100644 --- a/server/server.go +++ b/server/server.go @@ -58,7 +58,7 @@ type Server struct { smtpServer *smtp.Server smtpServerBackend *smtpBackend smtpSender mailer - mailSender *mail.Sender + mailSender emailVerifier topics map[string]*topic visitors map[string]*visitor // ip: or user: firebaseClient *firebaseClient @@ -80,9 +80,10 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error var ( // If changed, don't forget to update Android App and auth_sqlite.go - topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! - topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! - externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! + topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! + externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic + webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) @@ -116,6 +117,9 @@ var ( apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountEmailPath = "/v1/account/email" apiAccountEmailVerifyPath = "/v1/account/email/verify" + apiAccountEmailPrimaryPath = "/v1/account/email/primary" + apiAccountEmailResendPath = "/v1/account/email/resend" + webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -177,15 +181,16 @@ const ( // subscriber (if configured). func New(conf *Config) (*Server, error) { var mailer mailer - var mailSender *mail.Sender + var emailSender emailVerifier // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly if conf.SMTPSenderAddr != "" { - mailSender = mail.NewSender(&mail.Config{ + mailSender := mail.NewSender(&mail.Config{ SMTPAddr: conf.SMTPSenderAddr, SMTPUser: conf.SMTPSenderUser, SMTPPass: conf.SMTPSenderPass, From: conf.SMTPSenderFrom, }) mailer = &smtpSender{config: conf, sender: mailSender} + emailSender = mailSender } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -291,7 +296,7 @@ func New(conf *Config) (*Server, error) { attachment: attachmentStore, firebaseClient: firebaseClient, smtpSender: mailer, - mailSender: mailSender, + mailSender: emailSender, topics: topics, userManager: userManager, messages: messages, @@ -611,12 +616,16 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) - } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath { - return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v) } else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath { return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailVerifyPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountEmailVerify))(w, r, v) // No ensureUser: clicked from a mail client, possibly logged out } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath { return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailPrimaryPath { + return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath { + return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v) } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -659,12 +668,25 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) + } else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) { + return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) } return errHTTPNotFound } +// handleWebAppIndex serves the embedded web app's index for client-side (SPA) routes the +// browser router resolves, such as the magic-link landing pages. Because these URLs carry a +// one-time token in the path, the response is marked no-referrer (so the token can't leak to +// third parties via the Referer header) and noindex (so it never gets indexed). +func (s *Server) handleWebAppIndex(w http.ResponseWriter, r *http.Request, v *visitor) error { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Robots-Tag", "noindex") + r.URL.Path = webAppIndex + return s.handleStatic(w, r, v) +} + func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error { r.URL.Path = webAppIndex return s.handleStatic(w, r, v) @@ -715,21 +737,22 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi func (s *Server) configResponse() *apiConfigResponse { return &apiConfigResponse{ - BaseURL: "", // Will translate to window.location.origin - AppRoot: s.config.WebRoot, - EnableLogin: s.config.EnableLogin, - RequireLogin: s.config.RequireLogin, - EnableSignup: s.config.EnableSignup, - EnablePayments: s.config.StripeSecretKey != "", - EnableCalls: s.config.TwilioAccount != "", - EnableEmails: s.config.SMTPSenderFrom != "", - EnableEmailVerify: s.config.SMTPSenderVerify, - EnableReservations: s.config.EnableReservations, - EnableWebPush: s.config.WebPushPublicKey != "", - BillingContact: s.config.BillingContact, - WebPushPublicKey: s.config.WebPushPublicKey, - DisallowedTopics: s.config.DisallowedTopics, - ConfigHash: s.config.Hash(), + BaseURL: "", // Will translate to window.location.origin + AppRoot: s.config.WebRoot, + EnableLogin: s.config.EnableLogin, + RequireLogin: s.config.RequireLogin, + EnableSignup: s.config.EnableSignup, + EnablePayments: s.config.StripeSecretKey != "", + EnableCalls: s.config.TwilioAccount != "", + EnableEmails: s.config.SMTPSenderFrom != "", + EnableEmailVerify: s.config.SMTPSenderVerify, + EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url + EnableReservations: s.config.EnableReservations, + EnableWebPush: s.config.WebPushPublicKey != "", + BillingContact: s.config.BillingContact, + WebPushPublicKey: s.config.WebPushPublicKey, + DisallowedTopics: s.config.DisallowedTopics, + ConfigHash: s.config.Hash(), } } diff --git a/server/server_account.go b/server/server_account.go index 7c5c03c3..fd3a5eba 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -15,8 +15,9 @@ import ( ) const ( - syncTopicAccountSyncEvent = "sync" - tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much + syncTopicAccountSyncEvent = "sync" + tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much + emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -168,6 +169,18 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis if len(emails) > 0 { response.Emails = emails } + primaryEmail, err := s.userManager.PrimaryEmail(u.ID) + if err != nil { + return err + } + response.PrimaryEmail = primaryEmail + pendingEmails, err := s.userManager.PendingEmails(u.ID) + if err != nil { + return err + } + if len(pendingEmails) > 0 { + response.PendingEmails = pendingEmails + } } } else { response.Username = user.Everyone @@ -615,74 +628,149 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a +// magic-link token, stores a pending verification, and emails the link. The address is NOT +// added to the verified list until the user clicks the link (handleAccountEmailVerify). +func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid } - // Check user is allowed to add emails - if u == nil { - return errHTTPUnauthorized - } else if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 { + // Check user is allowed to add emails (the tier email limit gates the feature) + if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 { return errHTTPUnauthorized } else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 { return errHTTPUnauthorized } - // Check if email already exists + // Reject if already verified on this account (pending re-requests are fine -- they replace) emails, err := s.userManager.Emails(u.ID) if err != nil { return err } else if util.Contains(emails, req.Email) { return errHTTPConflictEmailExists } - // Check email rate limit (counts against the user's email quota) + // Rate limit (counts against the user's email quota) if !v.EmailAllowed() { return errHTTPTooManyRequestsLimitEmails } - // Send verification email - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification") - if err := s.mailSender.SendVerification(req.Email); err != nil { + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification") + if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleAccountEmailVerify performs verification from the (unauthenticated) landing page +// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's +// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required; +// the token binds the action to a user, so the click works from a logged-out mail client. +func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if req.Token == "" { + return errHTTPBadRequestEmailVerificationCodeInvalid + } + m, err := s.userManager.VerifyEmail(req.Token) + if errors.Is(err, user.ErrMagicLinkNotFound) { + return errHTTPBadRequestEmailVerificationCodeInvalid + } else if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified") + // Refresh the verified user's other sessions. The request is unauthenticated (v.User() is + // usually nil), so resolve the user from the token row and publish to their sync topic. + s.publishSyncEventForUserIDAsync(v, m.UserID) + return s.writeJSON(w, newSuccessResponse()) +} + +// handleAccountEmailDelete removes an email address, whether verified or still pending +// (DELETE /v1/account/email). Removing the primary leaves the account with no primary. +func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } else if !s.mailSender.CheckVerification(req.Email, req.Code) { - return errHTTPBadRequestEmailVerificationCodeInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified") - if err := s.userManager.AddEmail(u.ID, req.Email); err != nil { + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)") + if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil { + return err + } + // Also drop any pending verification for the address (no-op if there is none) + if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { +// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery) +// email (POST /v1/account/email/primary). +func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() - req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false) + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err - } - if !emailAddressRegex.MatchString(req.Email) { + } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email") - if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil { + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email") + err = s.userManager.SetPrimaryEmail(u.ID, req.Email) + if errors.Is(err, user.ErrEmailPrimaryElsewhere) { + return errHTTPConflictEmailPrimaryElsewhere + } else if errors.Is(err, user.ErrEmailNotFound) { + return errHTTPBadRequestEmailAddressNotVerified + } else if err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } +// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend). +func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + // Only resend for an address that is actually pending on this account + pending, err := s.userManager.PendingEmails(u.ID) + if err != nil { + return err + } else if !util.Contains(pending, req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + if !v.EmailAllowed() { + return errHTTPTooManyRequestsLimitEmails + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification") + if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +// enqueueEmailVerification generates a magic-link token for the given address, stores the +// pending verification (replacing any existing one), and emails the link. Shared by the add, +// resend, signup, and Stripe paths. Requires base-url to build an absolute link. +func (s *Server) enqueueEmailVerification(userID, email string) error { + if s.config.BaseURL == "" { + return errHTTPInternalErrorMissingBaseURL + } + token, err := s.userManager.CreateMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry) + if err != nil { + return err + } + link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token + return s.mailSender.SendEmailVerification(email, link) +} + // convertEmailAddress checks the email address against the user's verified email list. // If smtp-sender-verify is false (default), the email is passed through as-is for // backwards compatibility. If true, the user must be authenticated and the email must be @@ -721,9 +809,30 @@ func (s *Server) publishSyncEventAsync(v *visitor) { }() } -// publishSyncEvent publishes a sync message to the user's sync topic +// publishSyncEvent publishes a sync message to the authenticated user's sync topic func (s *Server) publishSyncEvent(v *visitor) error { - u := v.User() + return s.publishSyncEventForUser(v, v.User()) +} + +// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the +// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where +// the request visitor has no associated user but the token identifies the account to refresh. +func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) { + go func() { + u, err := s.userManager.UserByID(userID) + if err != nil { + logv(v).Err(err).Trace("Error loading user for sync event") + return + } + if err := s.publishSyncEventForUser(v, u); err != nil { + logv(v).Err(err).Trace("Error publishing to user's sync topic") + } + }() +} + +// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as +// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic. +func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error { if u == nil || u.SyncTopic == "" { return nil } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go new file mode 100644 index 00000000..b594684d --- /dev/null +++ b/server/server_account_email_test.go @@ -0,0 +1,190 @@ +package server + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +// captureMailer is a fake emailVerifier that records the magic links it is asked to send, so +// tests can "click" them without a real SMTP server. +type captureMailer struct { + verifyLinks map[string]string // email -> verification link + resetLinks map[string]string // email -> reset link +} + +func newCaptureMailer() *captureMailer { + return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}} +} + +func (c *captureMailer) SendEmailVerification(to, link string) error { + c.verifyLinks[to] = link + return nil +} + +func (c *captureMailer) SendPasswordReset(to, link string) error { + c.resetLinks[to] = link + return nil +} + +func (c *captureMailer) Close() {} + +// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) +// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. +func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} + return s, mailer, auth +} + +func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse { + rr := request(t, s, "GET", "/v1/account", "", auth) + require.Equal(t, 200, rr.Code) + account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + return account +} + +func tokenFromLink(t *testing.T, link, prefix string) string { + require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix) + return strings.TrimPrefix(link, prefix) +} + +func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Start verification + rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) + require.Equal(t, 200, rr.Code) + + // Pending, not yet verified, no primary + account := getAccount(t, s, auth) + require.Equal(t, []string{"ben@example.com"}, account.PendingEmails) + require.Empty(t, account.Emails) + require.Equal(t, "", account.PrimaryEmail) + + // "Click" the captured link (unauthenticated POST) + token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil) + require.Equal(t, 200, rr.Code) + + // Now verified + primary, no longer pending + account = getAccount(t, s, auth) + require.Equal(t, []string{"ben@example.com"}, account.Emails) + require.Equal(t, "ben@example.com", account.PrimaryEmail) + require.Empty(t, account.PendingEmails) + }) +} + +func TestAccount_Email_VerifyInvalidToken(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code) + + // Empty token also rejected + rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil) + require.Equal(t, 400, rr.Code) + }) +} + +func TestAccount_Email_DeletePending(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + require.Equal(t, []string{"ben@example.com"}, getAccount(t, s, auth).PendingEmails) + + // Deleting the pending address clears it (no verification ever happened) + require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + account := getAccount(t, s, auth) + require.Empty(t, account.PendingEmails) + require.Empty(t, account.Emails) + }) +} + +func TestAccount_Email_Resend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + firstLink := mailer.verifyLinks["ben@example.com"] + require.NotEmpty(t, firstLink) + + // Resend issues a fresh link (the old one is replaced) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code) + require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"]) + + // The old token no longer verifies; the new one does + oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code) + newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code) + + // Resending for a non-pending address is rejected + require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code) + }) +} + +func TestAccount_Email_SetPrimaryCollision(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // ben verifies shared@ -> becomes his primary + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code) + benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code) + require.Equal(t, "shared@example.com", getAccount(t, s, auth).PrimaryEmail) + + // alice verifies the same address -> allowed as secondary, but it is not her primary + require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code) + aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code) + aliceAccount := getAccount(t, s, aliceAuth) + require.Equal(t, []string{"shared@example.com"}, aliceAccount.Emails) + require.Equal(t, "", aliceAccount.PrimaryEmail) + + // alice trying to promote it to primary collides with ben's + rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code) + }) +} + +func TestAccount_Email_AddDuplicateVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) + token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + + // Adding the same already-verified address is a conflict + rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code) + }) +} diff --git a/server/server_test.go b/server/server_test.go index bb4ddfba..1d19815b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1706,11 +1706,11 @@ func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) { // Create a user without a tier require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) - // Verify email request should NOT return 401 - response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + // Starting email verification should NOT return 401 + response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) - // The request will fail (SMTP not available), but it must NOT be a 401 + // The request may fail (SMTP not available), but it must NOT be a 401 require.NotEqual(t, 401, response.Code) }) } @@ -1731,7 +1731,7 @@ func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) // Should be rejected with 401 since email sending is disabled - response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{ + response := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 401, response.Code) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 885f806b..1e7460e8 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -20,6 +20,14 @@ type mailer interface { Counts() (total int64, success int64, failure int64) } +// emailVerifier sends the magic-link emails for email verification and password reset. +// *mail.Sender implements it; tests inject a fake to capture the generated links. +type emailVerifier interface { + SendEmailVerification(to, link string) error + SendPasswordReset(to, link string) error + Close() +} + type smtpSender struct { config *Config sender *mail.Sender diff --git a/server/types.go b/server/types.go index 1f69d3de..b51e9f00 100644 --- a/server/types.go +++ b/server/types.go @@ -226,13 +226,16 @@ type apiAccountPhoneNumberAddRequest struct { Code string `json:"code"` // Only set when adding a phone number } -type apiAccountEmailVerifyRequest struct { +// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend +// endpoints (all of which identify an email by address in the JSON body). +type apiAccountEmailRequest struct { Email string `json:"email"` } -type apiAccountEmailAddRequest struct { - Email string `json:"email"` - Code string `json:"code"` +// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated) +// from the verification landing page. +type apiAccountEmailVerifyRequest struct { + Token string `json:"token"` } type apiAccountTier struct { @@ -292,6 +295,8 @@ type apiAccountResponse struct { Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"` Emails []string `json:"emails,omitempty"` + PrimaryEmail string `json:"primary_email,omitempty"` // The verified recovery email, if set + PendingEmails []string `json:"pending_emails,omitempty"` // Unverified addresses awaiting a magic-link click Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` @@ -304,21 +309,22 @@ type apiAccountReservationRequest struct { } type apiConfigResponse struct { - BaseURL string `json:"base_url"` - AppRoot string `json:"app_root"` - EnableLogin bool `json:"enable_login"` - RequireLogin bool `json:"require_login"` - EnableSignup bool `json:"enable_signup"` - EnablePayments bool `json:"enable_payments"` - EnableCalls bool `json:"enable_calls"` - EnableEmails bool `json:"enable_emails"` - EnableEmailVerify bool `json:"enable_email_verify"` - EnableReservations bool `json:"enable_reservations"` - EnableWebPush bool `json:"enable_web_push"` - BillingContact string `json:"billing_contact"` - WebPushPublicKey string `json:"web_push_public_key"` - DisallowedTopics []string `json:"disallowed_topics"` - ConfigHash string `json:"config_hash"` + BaseURL string `json:"base_url"` + AppRoot string `json:"app_root"` + EnableLogin bool `json:"enable_login"` + RequireLogin bool `json:"require_login"` + EnableSignup bool `json:"enable_signup"` + EnablePayments bool `json:"enable_payments"` + EnableCalls bool `json:"enable_calls"` + EnableEmails bool `json:"enable_emails"` + EnableEmailVerify bool `json:"enable_email_verify"` + EnableResetPassword bool `json:"enable_reset_password"` + EnableReservations bool `json:"enable_reservations"` + EnableWebPush bool `json:"enable_web_push"` + BillingContact string `json:"billing_contact"` + WebPushPublicKey string `json:"web_push_public_key"` + DisallowedTopics []string `json:"disallowed_topics"` + ConfigHash string `json:"config_hash"` } type apiAccountBillingPrices struct { diff --git a/user/magic_link_test.go b/user/magic_link_test.go index 4be1fe55..fd96bb1a 100644 --- a/user/magic_link_test.go +++ b/user/magic_link_test.go @@ -7,18 +7,11 @@ import ( "github.com/stretchr/testify/require" ) -// addVerifyLink generates a raw token, stores an email-verification magic link for it, and -// returns the raw token so the test can "click" it via VerifyEmail. -func addVerifyLink(t *testing.T, a *Manager, userID, email string, expires int64) string { - raw := generateLinkToken() - require.Nil(t, a.AddMagicLink(&MagicLink{ - TokenHash: hashToken(raw), - Kind: MagicLinkKindEmailVerify, - UserID: userID, - Email: email, - Expires: expires, - Created: time.Now().Unix(), - })) +// addVerifyLink stores an email-verification magic link and returns the raw token so the test +// can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { + raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) + require.Nil(t, err) return raw } @@ -29,7 +22,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) // Before verifying: pending, not yet verified, no primary pending, err := a.PendingEmails(phil.ID) @@ -43,7 +36,7 @@ func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { require.Equal(t, "", primary) // Verify: the first verified email auto-becomes primary - m, err := a.VerifyEmail(hashToken(raw)) + m, err := a.VerifyEmail(raw) require.Nil(t, err) require.Equal(t, "phil@example.com", m.Email) @@ -71,12 +64,12 @@ func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", time.Now().Add(24*time.Hour).Unix()) - _, err = a.VerifyEmail(hashToken(raw1)) + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw1) require.Nil(t, err) - raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", time.Now().Add(24*time.Hour).Unix()) - _, err = a.VerifyEmail(hashToken(raw2)) + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw2) require.Nil(t, err) // Both verified, but primary is still the first @@ -100,16 +93,14 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { require.Nil(t, err) // phil verifies shared@ first -> becomes his primary - rawPhil := addVerifyLink(t, a, phil.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) - _, err = a.VerifyEmail(hashToken(rawPhil)) + _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) require.Nil(t, err) primary, err := a.PrimaryEmail(phil.ID) require.Nil(t, err) require.Equal(t, "shared@example.com", primary) // ben verifies the same address -> allowed as secondary, but NOT his primary - rawBen := addVerifyLink(t, a, ben.ID, "shared@example.com", time.Now().Add(24*time.Hour).Unix()) - _, err = a.VerifyEmail(hashToken(rawBen)) + _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) require.Nil(t, err) emails, err := a.Emails(ben.ID) require.Nil(t, err) @@ -120,7 +111,7 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { // Explicitly promoting ben's copy to primary collides with phil's require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) - // ...and phil keeps his primary (the failed promotion rolled back ben's clear, which was a no-op anyway) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear) primary, err = a.PrimaryEmail(phil.ID) require.Nil(t, err) require.Equal(t, "shared@example.com", primary) @@ -144,8 +135,8 @@ func TestUser_MagicLink_Expired(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(-time.Minute).Unix()) - _, err = a.VerifyEmail(hashToken(raw)) + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) + _, err = a.VerifyEmail(raw) require.ErrorIs(t, err, ErrMagicLinkNotFound) // Nothing got verified @@ -162,11 +153,11 @@ func TestUser_MagicLink_SingleUse(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) - _, err = a.VerifyEmail(hashToken(raw)) + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw) require.Nil(t, err) // Second click: token already consumed - _, err = a.VerifyEmail(hashToken(raw)) + _, err = a.VerifyEmail(raw) require.ErrorIs(t, err, ErrMagicLinkNotFound) }) } @@ -178,17 +169,17 @@ func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) - raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Now().Add(24*time.Hour).Unix()) + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) // Only one pending row remains; the old token no longer works pending, err := a.PendingEmails(phil.ID) require.Nil(t, err) require.Equal(t, []string{"phil@example.com"}, pending) - _, err = a.MagicLinkByHash(hashToken(raw1)) + _, err = a.MagicLinkByToken(raw1) require.ErrorIs(t, err, ErrMagicLinkNotFound) - m, err := a.MagicLinkByHash(hashToken(raw2)) + m, err := a.MagicLinkByToken(raw2) require.Nil(t, err) require.Equal(t, "phil@example.com", m.Email) }) @@ -201,16 +192,10 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw := generateLinkToken() - require.Nil(t, a.AddMagicLink(&MagicLink{ - TokenHash: hashToken(raw), - Kind: MagicLinkKindPasswordReset, - UserID: phil.ID, - Expires: time.Now().Add(time.Hour).Unix(), - Created: time.Now().Unix(), - })) + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) - m, err := a.MagicLinkByHash(hashToken(raw)) + m, err := a.MagicLinkByToken(raw) require.Nil(t, err) require.Equal(t, MagicLinkKindPasswordReset, m.Kind) require.Equal(t, phil.ID, m.UserID) @@ -222,20 +207,14 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { require.Equal(t, 0, len(pending)) // New request replaces the old token - raw2 := generateLinkToken() - require.Nil(t, a.AddMagicLink(&MagicLink{ - TokenHash: hashToken(raw2), - Kind: MagicLinkKindPasswordReset, - UserID: phil.ID, - Expires: time.Now().Add(time.Hour).Unix(), - Created: time.Now().Unix(), - })) - _, err = a.MagicLinkByHash(hashToken(raw)) + raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.MagicLinkByToken(raw) require.ErrorIs(t, err, ErrMagicLinkNotFound) // Single use: deleting consumes it - require.Nil(t, a.DeleteMagicLink(hashToken(raw2))) - _, err = a.MagicLinkByHash(hashToken(raw2)) + require.Nil(t, a.DeleteMagicLinkByToken(raw2)) + _, err = a.MagicLinkByToken(raw2) require.ErrorIs(t, err, ErrMagicLinkNotFound) }) } @@ -247,14 +226,14 @@ func TestUser_MagicLink_Reaper(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - expired := addVerifyLink(t, a, phil.ID, "expired@example.com", time.Now().Add(-time.Hour).Unix()) - valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Now().Add(time.Hour).Unix()) + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) require.Nil(t, a.deleteExpiredMagicLinks()) - _, err = a.MagicLinkByHash(hashToken(expired)) + _, err = a.MagicLinkByToken(expired) require.ErrorIs(t, err, ErrMagicLinkNotFound) - m, err := a.MagicLinkByHash(hashToken(valid)) + m, err := a.MagicLinkByToken(valid) require.Nil(t, err) require.Equal(t, "valid@example.com", m.Email) }) diff --git a/user/manager.go b/user/manager.go index e6df6910..4c62f940 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1556,6 +1556,37 @@ func (a *Manager) SetPrimaryEmail(userID, email string) error { }) } +// CreateMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, +// replacing any existing link in the same scope), and returns the RAW token for use in the +// emailed link. Only the hash is persisted; the raw token is never stored. email is the +// address being verified for email_verify, and "" for password_reset. +func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { + raw := generateLinkToken() + now := time.Now() + m := &MagicLink{ + TokenHash: hashToken(raw), + Kind: kind, + UserID: userID, + Email: email, + Expires: now.Add(ttl).Unix(), + Created: now.Unix(), + } + if err := a.AddMagicLink(m); err != nil { + return "", err + } + return raw, nil +} + +// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. +func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) { + return a.MagicLinkByHash(hashToken(rawToken)) +} + +// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume). +func (a *Manager) DeleteMagicLinkByToken(rawToken string) error { + return a.DeleteMagicLink(hashToken(rawToken)) +} + // AddMagicLink stores a pending magic link, replacing any existing link in the same scope: // for email_verify that is the (user_id, email) pair (one pending verification per address); // for password_reset that is the user_id (one active reset per account). The replace-delete and @@ -1606,12 +1637,21 @@ func (a *Manager) DeleteMagicLink(tokenHash string) error { return err } -// VerifyEmail consumes an email-verification magic link: after validating the token (kind + -// expiry), it deletes the link, adds the address to the user's verified emails, and -- if the -// user has no primary email yet and the address is not already primary on another account -- -// promotes the new address to primary. All mutations run in one transaction. A primary -// collision simply leaves the address verified but non-primary. Returns the consumed link. -func (a *Manager) VerifyEmail(tokenHash string) (*MagicLink, error) { +// DeleteEmailVerification removes any pending email verification for (userID, email). Used when +// an unverified (pending) address is cancelled/deleted from the account. +func (a *Manager) DeleteEmailVerification(userID, email string) error { + _, err := a.db.Exec(a.queries.deleteVerifyScope, userID, email) + return err +} + +// VerifyEmail consumes an email-verification magic link, identified by its raw token: after +// validating the token (kind + expiry), it deletes the link, adds the address to the user's +// verified emails, and -- if the user has no primary email yet and the address is not already +// primary on another account -- promotes the new address to primary. All mutations run in one +// transaction. A primary collision simply leaves the address verified but non-primary. Returns +// the consumed link. +func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { + tokenHash := hashToken(rawToken) m, err := a.MagicLinkByHash(tokenHash) if err != nil { return nil, err diff --git a/user/manager_test.go b/user/manager_test.go index 3845752e..7e05f5db 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1847,16 +1847,9 @@ func TestMigrationFrom7(t *testing.T) { require.Equal(t, "", primary) // The new magic-link machinery works post-migration - raw := generateLinkToken() - require.Nil(t, a.AddMagicLink(&MagicLink{ - TokenHash: hashToken(raw), - Kind: MagicLinkKindEmailVerify, - UserID: "u_phil", - Email: "new@example.com", - Expires: time.Now().Add(24 * time.Hour).Unix(), - Created: time.Now().Unix(), - })) - m, err := a.VerifyEmail(hashToken(raw)) + raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, "u_phil", "new@example.com", 24*time.Hour) + require.Nil(t, err) + m, err := a.VerifyEmail(raw) require.Nil(t, err) require.Equal(t, "new@example.com", m.Email) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2e06cc64..2dff3100 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -3,8 +3,15 @@ "common_save": "Save", "common_add": "Add", "common_back": "Back", + "common_close": "Close", "common_copy_to_clipboard": "Copy to clipboard", "common_refresh": "Refresh", + "email_verify_progress_title": "Verifying your email...", + "email_verify_success_title": "Email verified", + "email_verify_success_description": "Your email address has been verified and added to your account.", + "email_verify_error_title": "Verification failed", + "email_verify_error_description": "This verification link is invalid or has expired. You can request a new one from your account settings.", + "email_verify_button_account": "Go to account", "version_update_available_title": "New version available", "version_update_available_description": "The ntfy server has been updated. Please refresh the page.", "signup_title": "Create a ntfy account", @@ -216,18 +223,24 @@ "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_call": "Call", "account_basics_emails_title": "Email addresses", - "account_basics_emails_description": "For email notifications", - "account_basics_emails_no_emails_yet": "No verified emails yet", + "account_basics_emails_description": "For email notifications and password reset", + "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", + "account_basics_emails_primary_badge": "Primary", + "account_basics_emails_unverified": "unverified", + "account_basics_emails_set_primary": "Set as recovery email", + "account_basics_emails_delete": "Remove", + "account_basics_emails_cancel": "Cancel", + "account_basics_emails_resend": "Resend verification email", + "account_basics_emails_resent": "Verification email sent, check your inbox", + "account_basics_emails_primary_elsewhere": "This email is the recovery email on another account", + "account_basics_emails_no_recovery_warning": "No recovery email set. You will not be able to reset your password. Add and verify an email below, then set it as your recovery email.", "account_basics_emails_dialog_title": "Add email address", - "account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.", + "account_basics_emails_dialog_description": "Enter an email address to add it to your account. We will send a verification link to confirm it is yours.", "account_basics_emails_dialog_email_label": "Email address", "account_basics_emails_dialog_email_placeholder": "e.g. user@example.com", - "account_basics_emails_dialog_verify_button": "Add email", - "account_basics_emails_dialog_code_label": "Verification code", - "account_basics_emails_dialog_code_placeholder": "e.g. 123456", - "account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired", - "account_basics_emails_dialog_check_verification_button": "Confirm", + "account_basics_emails_dialog_verify_button": "Send verification link", + "account_basics_emails_dialog_check_inbox": "Check your inbox and click the verification link to confirm this email address. It will appear as unverified until you do.", "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 4fadb8c5..e9d21d65 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -4,6 +4,8 @@ import { accountBillingSubscriptionUrl, accountEmailUrl, accountEmailVerifyUrl, + accountEmailPrimaryUrl, + accountEmailResendUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, @@ -342,9 +344,11 @@ class AccountApi { }); } - async verifyEmail(email) { - const url = accountEmailVerifyUrl(config.base_url); - console.log(`[AccountApi] Sending email verification ${url}`); + // startEmailVerification begins adding an email: the server stores a pending verification and + // emails a magic link. The address is not verified until the link is clicked. + async startEmailVerification(email) { + const url = accountEmailUrl(config.base_url); + console.log(`[AccountApi] Starting email verification ${url}`); await fetchOrThrow(url, { method: "PUT", headers: withBearerAuth({}, session.token()), @@ -354,15 +358,41 @@ class AccountApi { }); } - async addEmail(email, code) { - const url = accountEmailUrl(config.base_url); - console.log(`[AccountApi] Adding email with verification code ${url}`); + // verifyEmailToken performs verification from the magic-link landing page. It is unauthenticated: + // the token identifies the account, so this works even when clicked from a logged-out browser. + async verifyEmailToken(token) { + const url = accountEmailVerifyUrl(config.base_url); + console.log(`[AccountApi] Verifying email token ${url}`); await fetchOrThrow(url, { - method: "PUT", + method: "POST", + body: JSON.stringify({ + token, + }), + }); + } + + // resendEmailVerification re-sends the magic link for a pending (unverified) address. + async resendEmailVerification(email) { + const url = accountEmailResendUrl(config.base_url); + console.log(`[AccountApi] Resending email verification ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + email, + }), + }); + } + + // setPrimaryEmail marks an already-verified address as the primary (recovery) email. + async setPrimaryEmail(email) { + const url = accountEmailPrimaryUrl(config.base_url); + console.log(`[AccountApi] Setting primary email ${url}`); + await fetchOrThrow(url, { + method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ email, - code, }), }); } diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 4214ad84..5b749d14 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -51,7 +51,15 @@ export class EmailVerificationCodeInvalidError extends Error { static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid constructor() { - super("Email verification code invalid or expired"); + super("Email verification link invalid or expired"); + } +} + +export class EmailPrimaryElsewhereError extends Error { + static CODE = 40908; // errHTTPConflictEmailPrimaryElsewhere + + constructor() { + super("Email address is the recovery email on another account"); } } @@ -73,6 +81,8 @@ export const throwAppError = async (response) => { throw new IncorrectPasswordError(); } else if (error.code === EmailVerificationCodeInvalidError.CODE) { throw new EmailVerificationCodeInvalidError(); + } else if (error.code === EmailPrimaryElsewhereError.CODE) { + throw new EmailPrimaryElsewhereError(); } else if (error?.error) { throw new Error(`Error ${error.code}: ${error.error}`); } diff --git a/web/src/app/utils.js b/web/src/app/utils.js index db38801b..9bad68bf 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -35,6 +35,8 @@ export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`; export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`; +export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`; +export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`; export const validUrl = (url) => url.match(/^https?:\/\/.+/); diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 42402d41..a45dce09 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useContext, useState } from "react"; import { Alert, + Box, CardActions, CardContent, Chip, @@ -38,6 +39,9 @@ import { import EditIcon from "@mui/icons-material/Edit"; import { Trans, useTranslation } from "react-i18next"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import StarIcon from "@mui/icons-material/Star"; +import StarBorderIcon from "@mui/icons-material/StarBorder"; +import RefreshIcon from "@mui/icons-material/Refresh"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import CelebrationIcon from "@mui/icons-material/Celebration"; import CloseIcon from "@mui/icons-material/Close"; @@ -52,7 +56,7 @@ import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import DialogFooter from "./DialogFooter"; import { Paragraph } from "./styles"; -import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors"; +import { EmailPrimaryElsewhereError, IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import session from "../app/Session"; @@ -359,7 +363,7 @@ const Emails = () => { const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); + const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message const labelId = "prefVerifiedEmails"; const handleDialogOpen = () => { @@ -373,20 +377,34 @@ const Emails = () => { const handleCopy = (email) => { copyToClipboard(email); - setSnackOpen(true); + setSnack(t("account_basics_emails_copied_to_clipboard")); }; - const handleDelete = async (email) => { + // runEmailAction wraps an account API call with the shared error handling (redirect on + // unauthorized, surface a message otherwise). The account list refreshes via the sync event. + const runEmailAction = async (fn, errorMessage) => { try { - await accountApi.deleteEmail(email); + await fn(); } catch (e) { - console.log(`[Account] Error deleting email`, e); + console.log(`[Account] Email action failed`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); + } else if (e instanceof EmailPrimaryElsewhereError) { + setSnack(t("account_basics_emails_primary_elsewhere")); + } else { + setSnack(errorMessage ?? e.message); } } }; + const handleDelete = (email) => runEmailAction(() => accountApi.deleteEmail(email)); + const handleSetPrimary = (email) => runEmailAction(() => accountApi.setPrimaryEmail(email)); + const handleResend = (email) => + runEmailAction(async () => { + await accountApi.resendEmailVerification(email); + setSnack(t("account_basics_emails_resent")); + }); + if (!config.enable_email_verify) { return null; } @@ -407,35 +425,73 @@ const Emails = () => { ); } + const verifiedEmails = account?.emails ?? []; + const pendingEmails = account?.pending_emails ?? []; + const primaryEmail = account?.primary_email ?? ""; + const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === ""; + return (
- {account?.emails?.map((email) => ( - - {email} + {showNoRecoveryWarning && ( + + {t("account_basics_emails_no_recovery_warning")} + + )} + + {verifiedEmails.map((email) => { + const isPrimary = email === primaryEmail; + return ( + + {email} + {isPrimary && } + {!isPrimary && ( + + handleSetPrimary(email)}> + + + + )} + {isPrimary && } + + handleCopy(email)}> + + + + + handleDelete(email)}> + + + + + ); + })} + {pendingEmails.map((email) => ( + + + {email} ({t("account_basics_emails_unverified")}) + + + handleResend(email)}> + + - } - variant="outlined" - onClick={() => handleCopy(email)} - onDelete={() => handleDelete(email)} - /> - ))} - {!account?.emails && {t("account_basics_emails_no_emails_yet")}} - + + handleDelete(email)}> + + + + + ))} + {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} + +
- setSnackOpen(false)} - message={t("account_basics_emails_copied_to_clipboard")} - /> + setSnack("")} message={snack} />
); @@ -446,18 +502,19 @@ const AddEmailDialog = (props) => { const { t } = useTranslation(); const [error, setError] = useState(""); const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); const [sending, setSending] = useState(false); - const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const [sent, setSent] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const verifyEmail = async () => { + // handleSubmit starts verification: the server emails a magic link. The pending address shows + // up in the account list as "(unverified)" once the account refreshes. + const handleSubmit = async () => { try { setSending(true); - await accountApi.verifyEmail(email); - setVerificationCodeSent(true); + await accountApi.startEmailVerification(email); + setSent(true); } catch (e) { - console.log(`[Account] Error sending email verification`, e); + console.log(`[Account] Error starting email verification`, e); if (e instanceof UnauthorizedError) { await session.resetAndRedirect(routes.login); } else { @@ -468,81 +525,41 @@ const AddEmailDialog = (props) => { } }; - const checkVerifyEmail = async () => { - try { - setSending(true); - await accountApi.addEmail(email, code); - props.onClose(); - } catch (e) { - console.log(`[Account] Error confirming email verification`, e); - if (e instanceof UnauthorizedError) { - await session.resetAndRedirect(routes.login); - } else if (e instanceof EmailVerificationCodeInvalidError) { - setError(t("account_basics_emails_dialog_code_invalid")); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; - - const handleDialogSubmit = async () => { - if (!verificationCodeSent) { - await verifyEmail(); - } else { - await checkVerifyEmail(); - } - }; - - const handleCancel = () => { - if (verificationCodeSent) { - setVerificationCodeSent(false); - setCode(""); - } else { - props.onClose(); - } - }; - return ( - + {t("account_basics_emails_dialog_title")} - {t("account_basics_emails_dialog_description")} - {!verificationCodeSent && ( - setEmail(ev.target.value)} - fullWidth - variant="standard" - /> - )} - {verificationCodeSent && ( - setCode(ev.target.value)} - fullWidth - inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} - variant="standard" - /> + {sent ? ( + {t("account_basics_emails_dialog_check_inbox")} + ) : ( + <> + {t("account_basics_emails_dialog_description")} + setEmail(ev.target.value)} + fullWidth + variant="standard" + /> + )} - - + {sent ? ( + + ) : ( + <> + + + + )} ); diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 575304d4..ed1fff99 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -20,6 +20,7 @@ import Messaging from "./Messaging"; import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; +import EmailVerify from "./EmailVerify"; import initI18n from "../app/i18n"; // Translations! import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -63,6 +64,7 @@ const App = () => { } /> } /> + } /> }> } /> } /> diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx new file mode 100644 index 00000000..ce70a8d5 --- /dev/null +++ b/web/src/components/EmailVerify.jsx @@ -0,0 +1,74 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Typography, Button, Box, CircularProgress } from "@mui/material"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import { useParams, NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// EmailVerify is the magic-link landing page for email verification. It performs the verification +// via a POST (the GET that loads this page has no side effects, so link prefetchers / scanners +// cannot consume the single-use token). The raw token is stripped from the URL on load to keep +// it out of browser history and Referer headers. +const EmailVerify = () => { + const { t } = useTranslation(); + const { token } = useParams(); + const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error" + const ran = useRef(false); + + useEffect(() => { + if (ran.current) { + return; // Guard against double-invoke (e.g. React StrictMode) consuming the token twice + } + ran.current = true; + // Strip the token from the URL immediately (keep it out of history / Referer) + window.history.replaceState(null, "", routes.account); + (async () => { + try { + await accountApi.verifyEmailToken(token); + setStatus("success"); + } catch (e) { + console.log(`[EmailVerify] Verification failed`, e); + setStatus("error"); + } + })(); + }, [token]); + + return ( + + {status === "verifying" && ( + <> + + {t("email_verify_progress_title")} + + )} + {status === "success" && ( + <> + + {t("email_verify_success_title")} + {t("email_verify_success_description")} + + + )} + {status === "error" && ( + <> + + {t("email_verify_error_title")} + {t("email_verify_error_description")} + + + + + )} + + ); +}; + +export default EmailVerify; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 17e0eac6..d9c371eb 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -7,6 +7,7 @@ const routes = { app: config.app_root, account: "/account", settings: "/settings", + emailVerify: "/account/email/verify/:token", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic", forSubscription: (subscription) => { From 33ae31055cc7d8681ec6689660de5d5340697a18 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 14:23:32 -0400 Subject: [PATCH 10/46] Phase 3+4 --- cmd/user.go | 86 ++++++++++++++++++++ cmd/user_test.go | 43 ++++++++++ docs/releases.md | 17 ++++ server/errors.go | 1 + server/server.go | 40 ++++++---- server/server_account.go | 72 +++++++++++++++++ server/server_account_email_test.go | 83 +++++++++++++++++++ server/server_payments.go | 30 +++++++ server/server_payments_email_test.go | 114 +++++++++++++++++++++++++++ server/types.go | 13 +++ user/magic_link_test.go | 65 +++++++++++++++ user/manager.go | 31 ++++++++ web/public/static/langs/en.json | 15 ++++ web/src/app/AccountApi.js | 28 +++++++ web/src/app/utils.js | 2 + web/src/components/App.jsx | 2 + web/src/components/Login.jsx | 82 ++++++++++++++++++- web/src/components/PasswordReset.jsx | 105 ++++++++++++++++++++++++ web/src/components/routes.js | 1 + 19 files changed, 812 insertions(+), 18 deletions(-) create mode 100644 server/server_payments_email_test.go create mode 100644 web/src/components/PasswordReset.jsx diff --git a/cmd/user.go b/cmd/user.go index 2e5af5f4..84cba345 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -8,11 +8,13 @@ import ( "fmt" "os" "strings" + "time" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" @@ -32,6 +34,11 @@ var flagsUser = 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-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.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores"}), + 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: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), ) var cmdUser = &cli.Command{ @@ -98,6 +105,32 @@ 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. +`, + }, + { + Name: "password-reset", + Aliases: []string{"reset"}, + Usage: "Generates a password reset link for a user", + UsageText: "ntfy user password-reset [--send-email] USERNAME", + Action: execUserPasswordReset, + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, + }, + Description: `Generate a password reset link for the given user and print it to stdout. + +The user completes the reset by opening the link in a browser and choosing a new password; +the admin never learns or chooses the new password. The link is single-use and expires after +one hour. This is an admin override of the self-service reset flow -- unlike self-service, it +does not require the user to have a verified primary email (the token is bound to the user). + +With --send-email, the link is additionally emailed to the user's primary email address (this +requires SMTP to be configured and the user to have a verified primary email). + +Requires base-url to be configured so an absolute link can be generated. + +Example: + ntfy user password-reset phil # Print a reset link for user phil + ntfy user password-reset --send-email phil # Print and email the reset link `, }, { @@ -286,6 +319,59 @@ func execUserChangePass(c *cli.Context) error { return nil } +func execUserPasswordReset(c *cli.Context) error { + username := c.Args().Get(0) + sendEmail := c.Bool("send-email") + baseURL := strings.TrimSuffix(c.String("base-url"), "/") + if username == "" { + return errors.New("username expected, type 'ntfy user password-reset --help' for help") + } else if username == userEveryone || username == user.Everyone { + return errors.New("username not allowed") + } else if baseURL == "" { + return errors.New("base-url must be configured to generate a reset link") + } + manager, err := createUserManager(c) + if err != nil { + return err + } + u, err := manager.User(username) + if errors.Is(err, user.ErrUserNotFound) { + return fmt.Errorf("user %s does not exist", username) + } else if err != nil { + return err + } + // Resolve the primary email up front if we need to send -- fail before creating a token + var primaryEmail string + if sendEmail { + primaryEmail, err = manager.PrimaryEmail(u.ID) + if err != nil { + return err + } else if primaryEmail == "" { + return fmt.Errorf("user %s has no primary email; cannot send reset link (omit --send-email to just print it)", username) + } + } + // The reset token is bound to the user, not an email -- so this works even with no SMTP + token, err := manager.CreateMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) + if err != nil { + return err + } + link := baseURL + "/account/password/reset/" + token + fmt.Fprintln(c.App.Writer, link) + if sendEmail { + sender := mail.NewSender(&mail.Config{ + SMTPAddr: c.String("smtp-sender-addr"), + SMTPUser: c.String("smtp-sender-user"), + SMTPPass: c.String("smtp-sender-pass"), + From: c.String("smtp-sender-from"), + }) + if err := sender.SendPasswordReset(primaryEmail, link); err != nil { + return fmt.Errorf("failed to send reset email to %s: %w", primaryEmail, err) + } + fmt.Fprintf(c.App.ErrWriter, "reset link emailed to %s\n", primaryEmail) + } + return nil +} + func execUserChangeRole(c *cli.Context) error { username := c.Args().Get(0) role := user.Role(c.Args().Get(1)) diff --git a/cmd/user_test.go b/cmd/user_test.go index a6250b72..4dbca550 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -122,6 +122,49 @@ func TestCLI_User_Delete(t *testing.T) { require.Contains(t, err.Error(), "user phil does not exist") } +func TestCLI_User_PasswordReset(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, stdout, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // Prints a working-looking reset link when base-url is set + app, _, stdout, _ = newTestApp() + require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "phil")) + require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") +} + +func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "password-reset", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "base-url") +} + +func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + app, stdin, _, _ := newTestApp() + stdin.WriteString("mypass\nmypass") + require.Nil(t, runUserCommand(app, conf, "add", "phil")) + + // --send-email requires a primary email; phil has none + app, _, _, _ = newTestApp() + err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "--send-email", "phil") + require.Error(t, err) + require.Contains(t, err.Error(), "no primary email") +} + func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { configFile := filepath.Join(t.TempDir(), "server-dummy.yml") require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml diff --git a/docs/releases.md b/docs/releases.md index 5af903d7..d2de3d07 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1948,6 +1948,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +### ntfy server v2.25.0 (UNRELEASED) + +This release adds **password reset** via email, and reworks email verification to use durable, +link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at +signup; a user can reset their password only once they have a verified "primary" (recovery) +email. All of this rides on the existing SMTP configuration -- no new config flag. + +**Features:** + +* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user password-reset` CLI command for admins +* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI +* Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery + +**Bug fixes + maintenance:** + +* Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG + ### ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out diff --git a/server/errors.go b/server/errors.go index 5ac6ce38..51bfbe21 100644 --- a/server/errors.go +++ b/server/errors.go @@ -146,6 +146,7 @@ var ( errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", 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 95d2ecf7..3184478a 100644 --- a/server/server.go +++ b/server/server.go @@ -80,19 +80,20 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error var ( // If changed, don't forget to update Android App and auth_sqlite.go - topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! - topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! - externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic - webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) - jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) - ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) - rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) - wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) - authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) - publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) - clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) - sequenceIDRegex = topicRegex + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! + topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! + externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic + webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) + webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app) + jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) + ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) + rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) + wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) + authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) + publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) + updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) + sequenceIDRegex = topicRegex webConfigPath = "/config.js" webManifestPath = "/manifest.webmanifest" @@ -119,7 +120,10 @@ var ( apiAccountEmailVerifyPath = "/v1/account/email/verify" apiAccountEmailPrimaryPath = "/v1/account/email/primary" apiAccountEmailResendPath = "/v1/account/email/resend" - webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request" + apiAccountPasswordResetPath = "/v1/account/password/reset" + webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -626,6 +630,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.withAccountSync(s.handleAccountEmailSetPrimary))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountEmailResendPath { return s.ensureUser(s.ensureEmailsEnabled(s.handleAccountEmailResend))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetRequestPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordResetRequest))(w, r, v) // Unauthenticated + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordResetPath { + return s.ensureEmailsEnabled(s.limitRequests(s.handleAccountPasswordReset))(w, r, v) // Unauthenticated } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -668,8 +676,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v) } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) - } else if r.Method == http.MethodGet && webAppEmailVerifyRegex.MatchString(r.URL.Path) { - return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing page (client-side route) + } else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) { + return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing pages (client-side routes) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) } diff --git a/server/server_account.go b/server/server_account.go index fd3a5eba..f006f1ac 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -18,6 +18,7 @@ const ( syncTopicAccountSyncEvent = "sync" tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification + passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter) ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -771,6 +772,77 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { return s.mailSender.SendEmailVerification(email, link) } +// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, +// unauthenticated). It resolves the identifier (username or primary email) to at most one account +// and emails a reset link to that account's primary email. The response is always a uniform 200, +// regardless of whether anything matched, so it cannot be used to probe for accounts. +func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) + if !v.AccountCreationAllowed() { + return errHTTPTooManyRequestsLimitAccountCreation + } + v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing + identifier := strings.TrimSpace(req.Identifier) + if identifier != "" && s.config.BaseURL != "" { + if userID, email, ok := s.resolveResetTarget(identifier); ok { + token, err := s.userManager.CreateMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) + if err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token") + } else { + link := s.config.BaseURL + webAppPasswordResetPathPrefix + token + logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") + if err := s.mailSender.SendPasswordReset(email, link); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") + } + } + } else { + logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)") + } + } + return s.writeJSON(w, newSuccessResponse()) +} + +// resolveResetTarget resolves a reset identifier to a single account and its primary email. +// The identifier is tried first as a username, then as a primary email address. It returns +// ok=false if no account with a primary email matches (reset requires a verified primary email). +func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) { + if u, err := s.userManager.User(identifier); err == nil && u != nil { + if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" { + return u.ID, primary, true + } + } + if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil { + return uid, identifier, true + } + return "", "", false +} + +// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated): +// it validates the token and sets the new password. Existing access tokens stay valid. +func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error { + req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if req.Token == "" { + return errHTTPBadRequestResetLinkInvalid + } else if req.Password == "" { + return errHTTPBadRequest + } + err = s.userManager.ResetPassword(req.Token, req.Password) + if errors.Is(err, user.ErrMagicLinkNotFound) { + return errHTTPBadRequestResetLinkInvalid + } else if err != nil { + return err + } + logvr(v, r).Tag(tagAccount).Info("Password reset performed") + return s.writeJSON(w, newSuccessResponse()) +} + // convertEmailAddress checks the email address against the user's verified email list. // If smtp-sender-verify is false (default), the email is passed through as-is for // backwards compatibility. If true, the user must be authenticated and the email must be diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index b594684d..e8bbc920 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -173,6 +173,89 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) { }) } +// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email. +func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) { + require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code) + token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) +} + +// canLogin returns true if username/password authenticates (via the token-create endpoint). +func canLogin(t *testing.T, s *Server, username, password string) bool { + rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)}) + return rr.Code == 200 +} + +func TestAccount_PasswordReset_ByUsername(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + // Request reset by username + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + + // Confirm with a new password + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + + require.True(t, canLogin(t, s, "ben", "brandnew")) + require.False(t, canLogin(t, s, "ben", "ben")) + }) +} + +func TestAccount_PasswordReset_ByEmail(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + verifyEmailFor(t, s, mailer, auth, "ben@example.com") + + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/") + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + require.True(t, canLogin(t, s, "ben", "brandnew")) + }) +} + +func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Unknown identifier still returns a uniform 200, and no email is sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // ben exists but has no verified primary email -> uniform 200, nothing sent + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, mailer.resetLinks) + }) +} + +func TestAccount_PasswordReset_InvalidToken(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, _, _ := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code) + }) +} + func TestAccount_Email_AddDuplicateVerified(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, auth := newEmailTestServer(t, databaseURL) diff --git a/server/server_payments.go b/server/server_payments.go index 0226df4f..76d0b5ac 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -237,10 +237,40 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil { return err } + // Offer email recovery: auto-send a verification link to the billing email (best-effort). + if sess.CustomerDetails != nil { + s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email) + } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) return nil } +// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's +// billing email, so they can use it for password recovery -- but only if they have no verified +// email yet and the billing email is not already the recovery email on another account. On a +// collision (or any other skip), the generic "no recovery email set" warning on the account page +// nudges the user to add one. This is best-effort: failures are logged, never surfaced. +func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { + if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + return + } + emails, err := s.userManager.Emails(userID) + if err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification") + return + } else if len(emails) > 0 { + return // User already has a verified email -- don't nag + } + if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil { + logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification") + return // Collision: skip + let the generic no-recovery-email warning nudge instead + } + logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email") + if err := s.enqueueEmailVerification(userID, billingEmail); err != nil { + logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification") + } +} + // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go new file mode 100644 index 00000000..24fa7bb2 --- /dev/null +++ b/server/server_payments_email_test.go @@ -0,0 +1,114 @@ +//go:build !nopayments + +package server + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v74" + "heckel.io/ntfy/v2/user" +) + +// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given +// billing email on the session's CustomerDetails. +func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI { + m := &testStripeAPI{} + m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{ + ClientReferenceID: u.ID, + Customer: &stripe.Customer{ID: "acct_5555"}, + Subscription: &stripe.Subscription{ID: "sub_1234"}, + CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail}, + }, nil) + m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{ + ID: "sub_1234", + Status: stripe.SubscriptionStatusActive, + CurrentPeriodEnd: 123456789, + Items: &stripe.SubscriptionItemList{ + Data: []*stripe.SubscriptionItem{ + {Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}}, + }, + }, + }, nil) + m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil) + return m +} + +func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) { + c := newTestConfigWithAuthFile(t, databaseURL) + c.StripeSecretKey = "secret key" + c.BaseURL = "https://ntfy.example.com" + c.SMTPSenderAddr = "localhost:25" + c.SMTPSenderFrom = "noreply@example.com" + s := newTestServer(t, c) + mailer := newCaptureMailer() + s.mailSender = mailer + require.Nil(t, s.userManager.AddTier(&user.Tier{ + ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + return s, mailer, u +} + +func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + + // A verification link was auto-sent to the billing email; clicking it verifies + sets primary + link := mailer.verifyLinks["billing@example.com"] + require.NotEmpty(t, link) + token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + + emails, err := s.userManager.Emails(u.ID) + require.Nil(t, err) + require.Equal(t, []string{"billing@example.com"}, emails) + primary, err := s.userManager.PrimaryEmail(u.ID) + require.Nil(t, err) + require.Equal(t, "billing@example.com", primary) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // User already has a verified email -> no auto-send on checkout + require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} + +func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, u := newCheckoutEmailTestServer(t, databaseURL) + defer s.closeDatabases() + s.stripe = stripeCheckoutMock(u, "billing@example.com") + + // The billing email is already the recovery email on another account -> skip + require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + alice, err := s.userManager.User("alice") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com")) + + rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil) + require.Equal(t, 303, rr.Code) + require.Empty(t, mailer.verifyLinks) + }) +} diff --git a/server/types.go b/server/types.go index b51e9f00..963cb127 100644 --- a/server/types.go +++ b/server/types.go @@ -238,6 +238,19 @@ type apiAccountEmailVerifyRequest struct { Token string `json:"token"` } +// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint. +// The identifier is a username or a primary email address. +type apiAccountPasswordResetRequest struct { + Identifier string `json:"identifier"` +} + +// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm +// endpoint, submitted from the set-new-password landing page. +type apiAccountPasswordResetConfirmRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` diff --git a/user/magic_link_test.go b/user/magic_link_test.go index fd96bb1a..33053c05 100644 --- a/user/magic_link_test.go +++ b/user/magic_link_test.go @@ -239,6 +239,71 @@ func TestUser_MagicLink_Reaper(t *testing.T) { }) } +func TestUser_MagicLink_ResetPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + // Old password works before reset + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + + require.Nil(t, a.ResetPassword(raw, "newpass")) + + // New password works, old does not + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) + _, err = a.Authenticate("phil", "oldpass") + require.ErrorIs(t, err, ErrUnauthenticated) + + // Token is single-use + require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + // An email-verification token must not be usable for password reset... + verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) + require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) + + // ...and a reset token must not be usable for email verification + resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.VerifyEmail(resetToken) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Old password unchanged + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { a := newTestManager(t, newManager, PermissionDenyAll) diff --git a/user/manager.go b/user/manager.go index 4c62f940..4a7866c1 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1696,6 +1696,37 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { return m, nil } +// ResetPassword consumes a password-reset magic link, identified by its raw token: after +// validating the token (kind + expiry), it sets the user's password and deletes the link in one +// transaction. Existing access tokens are intentionally left valid (only the password changes). +// Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token. +func (a *Manager) ResetPassword(rawToken, password string) error { + m, err := a.MagicLinkByHash(hashToken(rawToken)) + if err != nil { + return err + } + if m.Kind != MagicLinkKindPasswordReset || time.Now().Unix() > m.Expires { + return ErrMagicLinkNotFound + } + u, err := a.UserByID(m.UserID) + if err != nil { + return err + } + hash, err := a.maybeHashPassword(password, false) + if err != nil { + return err + } + return db.ExecTx(a.db, func(tx *sql.Tx) error { + if err := a.changePasswordHashTx(tx, u.Name, hash); err != nil { + return err + } + if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, m.TokenHash); err != nil { + return err + } + return nil + }) +} + // deleteExpiredMagicLinks removes magic links whose expiry has passed. Expiry is also enforced // on read, so this is purely housekeeping to bound table growth; it runs from the reaper loop. func (a *Manager) deleteExpiredMagicLinks() error { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 2dff3100..ecae6954 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -27,6 +27,21 @@ "login_title": "Sign in to your ntfy account", "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", + "login_link_forgot_password": "Forgot password?", + "login_reset_dialog_title": "Reset password", + "login_reset_dialog_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.", + "login_reset_dialog_identifier_label": "Username or email", + "login_reset_dialog_button_submit": "Send reset link", + "login_reset_dialog_sent": "If an account exists, we've emailed a link to reset your password. Please check your inbox.", + "reset_password_title": "Set a new password", + "reset_password_form_password": "New password", + "reset_password_form_confirm": "Confirm new password", + "reset_password_form_button_submit": "Set password", + "reset_password_form_passwords_no_match": "Passwords do not match", + "reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.", + "reset_password_success_title": "Password changed", + "reset_password_success_description": "Your password has been changed. You can now sign in with your new password.", + "reset_password_button_login": "Sign in", "login_disabled": "Login is disabled", "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index e9d21d65..823eba53 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -6,6 +6,8 @@ import { accountEmailVerifyUrl, accountEmailPrimaryUrl, accountEmailResendUrl, + accountPasswordResetRequestUrl, + accountPasswordResetUrl, accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, @@ -397,6 +399,32 @@ class AccountApi { }); } + // requestPasswordReset starts the (unauthenticated) reset flow. The identifier is a username or + // primary email. The server always responds uniformly, regardless of whether an account matched. + async requestPasswordReset(identifier) { + const url = accountPasswordResetRequestUrl(config.base_url); + console.log(`[AccountApi] Requesting password reset ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + identifier, + }), + }); + } + + // resetPassword performs the (unauthenticated) reset from the set-new-password landing page. + async resetPassword(token, password) { + const url = accountPasswordResetUrl(config.base_url); + console.log(`[AccountApi] Resetting password ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: JSON.stringify({ + token, + password, + }), + }); + } + async deleteEmail(email) { const url = accountEmailUrl(config.base_url); console.log(`[AccountApi] Deleting email ${url}`); diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 9bad68bf..13f50ffd 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -37,6 +37,8 @@ export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`; export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`; export const accountEmailPrimaryUrl = (baseUrl) => `${baseUrl}/v1/account/email/primary`; export const accountEmailResendUrl = (baseUrl) => `${baseUrl}/v1/account/email/resend`; +export const accountPasswordResetRequestUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset/request`; +export const accountPasswordResetUrl = (baseUrl) => `${baseUrl}/v1/account/password/reset`; export const validUrl = (url) => url.match(/^https?:\/\/.+/); diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index ed1fff99..d9eb2d0b 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -21,6 +21,7 @@ import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; import EmailVerify from "./EmailVerify"; +import PasswordReset from "./PasswordReset"; import initI18n from "../app/i18n"; // Translations! import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -65,6 +66,7 @@ const App = () => { } /> } /> } /> + } /> }> } /> } /> diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 5c1af249..a95f9a14 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -1,6 +1,18 @@ import * as React from "react"; import { useState } from "react"; -import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; +import { + Typography, + TextField, + Button, + Box, + IconButton, + InputAdornment, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, +} from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -17,6 +29,7 @@ const Login = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + const [resetOpen, setResetOpen] = useState(false); const handleSubmit = async (event) => { event.preventDefault(); @@ -100,7 +113,13 @@ const Login = () => { )} - {/* This is where the password reset link would go */} + {config.enable_reset_password && ( +
+ +
+ )} {config.enable_signup && (
@@ -110,8 +129,67 @@ const Login = () => { )} + setResetOpen(false)} /> ); }; +// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The +// response is uniform, so the dialog always shows the same "if an account exists" confirmation. +const ForgotPasswordDialog = (props) => { + const { t } = useTranslation(); + const [identifier, setIdentifier] = useState(""); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + + const handleSubmit = async () => { + try { + setSending(true); + await accountApi.requestPasswordReset(identifier); + } catch (e) { + console.log(`[Login] Password reset request failed`, e); + } finally { + setSending(false); + setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe) + } + }; + + return ( + + {t("login_reset_dialog_title")} + + {sent ? ( + {t("login_reset_dialog_sent")} + ) : ( + <> + {t("login_reset_dialog_description")} + setIdentifier(ev.target.value.trim())} + fullWidth + variant="standard" + /> + + )} + + + {sent ? ( + + ) : ( + <> + + + + )} + + + ); +}; + export default Login; diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx new file mode 100644 index 00000000..0e004d04 --- /dev/null +++ b/web/src/components/PasswordReset.jsx @@ -0,0 +1,105 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Typography, TextField, Button, Box } from "@mui/material"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import { useParams, NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// PasswordReset is the magic-link landing page for setting a new password. There is no +// pre-validation: the form renders directly and an invalid/expired token surfaces as an error on +// submit. The raw token is stripped from the URL on load (kept out of history / Referer). +const PasswordReset = () => { + const { t } = useTranslation(); + const { token: tokenParam } = useParams(); + const token = useRef(tokenParam); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [sending, setSending] = useState(false); + const [done, setDone] = useState(false); + + useEffect(() => { + // Strip the token from the URL bar immediately (keep it out of history / Referer) + window.history.replaceState(null, "", routes.login); + }, []); + + const handleSubmit = async (event) => { + event.preventDefault(); + if (password !== confirm) { + setError(t("reset_password_form_passwords_no_match")); + return; + } + try { + setSending(true); + setError(""); + await accountApi.resetPassword(token.current, password); + setDone(true); + } catch (e) { + console.log(`[PasswordReset] Reset failed`, e); + setError(t("reset_password_form_error_invalid")); + } finally { + setSending(false); + } + }; + + if (done) { + return ( + + + {t("reset_password_success_title")} + {t("reset_password_success_description")} + + + ); + } + + return ( + + {t("reset_password_title")} + + setPassword(ev.target.value.trim())} + autoComplete="new-password" + autoFocus + /> + setConfirm(ev.target.value.trim())} + autoComplete="new-password" + /> + + {error && ( + + + {error} + + )} + + + ); +}; + +export default PasswordReset; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index d9c371eb..6e649df3 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -8,6 +8,7 @@ const routes = { account: "/account", settings: "/settings", emailVerify: "/account/email/verify/:token", + passwordReset: "/account/password/reset/:token", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic", forSubscription: (subscription) => { From 4516adea368df7dc056906004f5bce3a00fbaa76 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 17:36:40 -0400 Subject: [PATCH 11/46] Lots of refinement --- cmd/user.go | 20 ++-- cmd/user_test.go | 32 ++++-- server/server_account.go | 28 +++-- server/server_account_email_test.go | 112 +++++++++++++++++--- server/server_payments.go | 3 +- server/types.go | 13 ++- user/magic_link_test.go | 47 ++++++++ user/manager.go | 15 ++- web/public/static/langs/en.json | 18 ++-- web/src/components/Account.jsx | 153 +++++++++++++++++---------- web/src/components/App.jsx | 2 + web/src/components/EmailVerify.jsx | 31 +++--- web/src/components/Login.jsx | 78 +------------- web/src/components/PasswordReset.jsx | 11 +- web/src/components/ResetPassword.jsx | 95 +++++++++++++++++ web/src/components/routes.js | 1 + 16 files changed, 460 insertions(+), 199 deletions(-) create mode 100644 web/src/components/ResetPassword.jsx diff --git a/cmd/user.go b/cmd/user.go index 84cba345..8c65221d 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -44,7 +44,7 @@ var flagsUser = append( var cmdUser = &cli.Command{ Name: "user", Usage: "Manage/show users", - UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...", + UsageText: "ntfy user [list|add|remove|change-pass|reset-pass|change-role] ...", Flags: flagsUser, Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc), Category: categoryServer, @@ -108,11 +108,11 @@ directly the bcrypt hash. This is useful if you are updating users via scripts. `, }, { - Name: "password-reset", - Aliases: []string{"reset"}, + Name: "reset-pass", + Aliases: []string{"rp"}, Usage: "Generates a password reset link for a user", - UsageText: "ntfy user password-reset [--send-email] USERNAME", - Action: execUserPasswordReset, + UsageText: "ntfy user reset-pass [--send-email] USERNAME", + Action: execUserResetPass, Flags: []cli.Flag{ &cli.BoolFlag{Name: "send-email", Aliases: []string{"e"}, Usage: "also email the reset link to the user's primary email"}, }, @@ -129,8 +129,8 @@ requires SMTP to be configured and the user to have a verified primary email). Requires base-url to be configured so an absolute link can be generated. Example: - ntfy user password-reset phil # Print a reset link for user phil - ntfy user password-reset --send-email phil # Print and email the reset link + ntfy user reset-pass phil # Print a reset link for user phil + ntfy user reset-pass --send-email phil # Print and email the reset link `, }, { @@ -319,12 +319,12 @@ func execUserChangePass(c *cli.Context) error { return nil } -func execUserPasswordReset(c *cli.Context) error { +func execUserResetPass(c *cli.Context) error { username := c.Args().Get(0) sendEmail := c.Bool("send-email") baseURL := strings.TrimSuffix(c.String("base-url"), "/") if username == "" { - return errors.New("username expected, type 'ntfy user password-reset --help' for help") + return errors.New("username expected, type 'ntfy user reset-pass --help' for help") } else if username == userEveryone || username == user.Everyone { return errors.New("username not allowed") } else if baseURL == "" { @@ -339,6 +339,8 @@ func execUserPasswordReset(c *cli.Context) error { return fmt.Errorf("user %s does not exist", username) } else if err != nil { return err + } else if u.Provisioned { + return fmt.Errorf("user %s is provisioned in the config file; its password cannot be reset", username) } // Resolve the primary email up front if we need to send -- fail before creating a token var primaryEmail string diff --git a/cmd/user_test.go b/cmd/user_test.go index 4dbca550..c5e1c44c 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -122,7 +122,7 @@ func TestCLI_User_Delete(t *testing.T) { require.Contains(t, err.Error(), "user phil does not exist") } -func TestCLI_User_PasswordReset(t *testing.T) { +func TestCLI_User_ResetPass(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) @@ -132,11 +132,11 @@ func TestCLI_User_PasswordReset(t *testing.T) { // Prints a working-looking reset link when base-url is set app, _, stdout, _ = newTestApp() - require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "phil")) + require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil")) require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") } -func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) { +func TestCLI_User_ResetPass_NoBaseURL(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) @@ -145,12 +145,12 @@ func TestCLI_User_PasswordReset_NoBaseURL(t *testing.T) { require.Nil(t, runUserCommand(app, conf, "add", "phil")) app, _, _, _ = newTestApp() - err := runUserCommand(app, conf, "password-reset", "phil") + err := runUserCommand(app, conf, "reset-pass", "phil") require.Error(t, err) require.Contains(t, err.Error(), "base-url") } -func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) { +func TestCLI_User_ResetPass_SendEmailNoPrimary(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) @@ -160,11 +160,31 @@ func TestCLI_User_PasswordReset_SendEmailNoPrimary(t *testing.T) { // --send-email requires a primary email; phil has none app, _, _, _ = newTestApp() - err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "password-reset", "--send-email", "phil") + err := runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "--send-email", "phil") require.Error(t, err) require.Contains(t, err.Error(), "no primary email") } +func TestCLI_User_ResetPass_ProvisionedRejected(t *testing.T) { + s, conf, port := newTestServerWithAuth(t) + defer test.StopServer(t, s, port) + + // Seed a provisioned user into the auth database via config provisioning + m, err := user.NewSQLiteManager(conf.AuthFile, "", &user.Config{ + ProvisionEnabled: true, + Users: []*user.User{ + {Name: "provuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, + }, + }) + require.Nil(t, err) + require.Nil(t, m.Close()) + + app, _, _, _ := newTestApp() + err = runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "provuser") + require.Error(t, err) + require.Contains(t, err.Error(), "provisioned") +} + func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) { configFile := filepath.Join(t.TempDir(), "server-dummy.yml") require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml diff --git a/server/server_account.go b/server/server_account.go index f006f1ac..214a71ba 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -167,20 +167,24 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis if err != nil { return err } - if len(emails) > 0 { - response.Emails = emails - } primaryEmail, err := s.userManager.PrimaryEmail(u.ID) if err != nil { return err } - response.PrimaryEmail = primaryEmail pendingEmails, err := s.userManager.PendingEmails(u.ID) if err != nil { return err } - if len(pendingEmails) > 0 { - response.PendingEmails = pendingEmails + // Combine verified (with primary flag) and pending (unverified) into one list + emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails)) + for _, email := range emails { + emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail}) + } + for _, email := range pendingEmails { + emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true}) + } + if len(emailInfos) > 0 { + response.Emails = emailInfos } } } else { @@ -718,6 +722,8 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid + } else if u.Provisioned { + return errHTTPConflictProvisionedUserChange // Provisioned users can't reset, so a recovery email is meaningless } logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email") err = s.userManager.SetPrimaryEmail(u.ID, req.Email) @@ -810,13 +816,15 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt // The identifier is tried first as a username, then as a primary email address. It returns // ok=false if no account with a primary email matches (reset requires a verified primary email). func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) { - if u, err := s.userManager.User(identifier); err == nil && u != nil { + if u, err := s.userManager.User(identifier); err == nil && u != nil && !u.Provisioned { if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" { return u.ID, primary, true } } if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil { - return uid, identifier, true + if u, uerr := s.userManager.UserByID(uid); uerr == nil && !u.Provisioned { + return uid, identifier, true + } } return "", "", false } @@ -834,8 +842,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque return errHTTPBadRequest } err = s.userManager.ResetPassword(req.Token, req.Password) - if errors.Is(err, user.ErrMagicLinkNotFound) { - return errHTTPBadRequestResetLinkInvalid + if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) { + return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that) } else if err != nil { return err } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index e8bbc920..38ed8d84 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -57,6 +57,37 @@ func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResp return account } +// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email +// list returned by GET /v1/account, so assertions stay readable. +func verifiedAddrs(account *apiAccountResponse) []string { + addrs := make([]string, 0) + for _, e := range account.Emails { + if !e.Pending { + addrs = append(addrs, e.Address) + } + } + return addrs +} + +func pendingAddrs(account *apiAccountResponse) []string { + addrs := make([]string, 0) + for _, e := range account.Emails { + if e.Pending { + addrs = append(addrs, e.Address) + } + } + return addrs +} + +func primaryAddr(account *apiAccountResponse) string { + for _, e := range account.Emails { + if e.Primary { + return e.Address + } + } + return "" +} + func tokenFromLink(t *testing.T, link, prefix string) string { require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix) return strings.TrimPrefix(link, prefix) @@ -73,9 +104,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) { // Pending, not yet verified, no primary account := getAccount(t, s, auth) - require.Equal(t, []string{"ben@example.com"}, account.PendingEmails) - require.Empty(t, account.Emails) - require.Equal(t, "", account.PrimaryEmail) + require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account)) + require.Empty(t, verifiedAddrs(account)) + require.Equal(t, "", primaryAddr(account)) // "Click" the captured link (unauthenticated POST) token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/") @@ -84,9 +115,9 @@ func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) { // Now verified + primary, no longer pending account = getAccount(t, s, auth) - require.Equal(t, []string{"ben@example.com"}, account.Emails) - require.Equal(t, "ben@example.com", account.PrimaryEmail) - require.Empty(t, account.PendingEmails) + require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account)) + require.Equal(t, "ben@example.com", primaryAddr(account)) + require.Empty(t, pendingAddrs(account)) }) } @@ -111,13 +142,13 @@ func TestAccount_Email_DeletePending(t *testing.T) { defer s.closeDatabases() require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) - require.Equal(t, []string{"ben@example.com"}, getAccount(t, s, auth).PendingEmails) + require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth))) // Deleting the pending address clears it (no verification ever happened) require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code) account := getAccount(t, s, auth) - require.Empty(t, account.PendingEmails) - require.Empty(t, account.Emails) + require.Empty(t, pendingAddrs(account)) + require.Empty(t, verifiedAddrs(account)) }) } @@ -154,7 +185,7 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) { require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code) benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code) - require.Equal(t, "shared@example.com", getAccount(t, s, auth).PrimaryEmail) + require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth))) // alice verifies the same address -> allowed as secondary, but it is not her primary require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) @@ -163,8 +194,8 @@ func TestAccount_Email_SetPrimaryCollision(t *testing.T) { aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/") require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code) aliceAccount := getAccount(t, s, aliceAuth) - require.Equal(t, []string{"shared@example.com"}, aliceAccount.Emails) - require.Equal(t, "", aliceAccount.PrimaryEmail) + require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount)) + require.Equal(t, "", primaryAddr(aliceAccount)) // alice trying to promote it to primary collides with ben's rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth) @@ -245,6 +276,63 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { }) } +func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + hash, err := user.HashPassword("provpass") + require.Nil(t, err) + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} + + // A provisioned user can verify an email, but it must NOT become their primary + verifyEmailFor(t, s, mailer, auth, "prov@example.com") + account := getAccount(t, s, auth) + require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account)) + require.Equal(t, "", primaryAddr(account)) + + // Explicitly setting it primary is rejected + rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov@example.com"}`, auth) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code) + }) +} + +func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + // Provision a user via config (AuthUsers), with email sending enabled + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + conf.AuthUsers = []*user.User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser}, + } + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + + // Give the provisioned user a verified primary email anyway + prov, err := s.userManager.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com")) + + // Reset request by username and by email -> uniform 200, but no email sent (can't reset) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code) + require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code) + require.Empty(t, mailer.resetLinks) + }) +} + func TestAccount_PasswordReset_InvalidToken(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, _, _ := newEmailTestServer(t, databaseURL) diff --git a/server/server_payments.go b/server/server_payments.go index 76d0b5ac..4b19a091 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -238,7 +238,8 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr return err } // Offer email recovery: auto-send a verification link to the billing email (best-effort). - if sess.CustomerDetails != nil { + // Provisioned users can't reset their password, so recovery setup doesn't apply to them. + if sess.CustomerDetails != nil && !u.Provisioned { s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email) } http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther) diff --git a/server/types.go b/server/types.go index 963cb127..08687fbb 100644 --- a/server/types.go +++ b/server/types.go @@ -287,6 +287,15 @@ type apiAccountReservation struct { Everyone string `json:"everyone"` } +// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account. +// Verified addresses have pending=false; exactly one verified address may be primary (the +// recovery email). Pending addresses are awaiting a magic-link click and are never primary. +type apiAccountEmailInfo struct { + Address string `json:"address"` + Primary bool `json:"primary,omitempty"` + Pending bool `json:"pending,omitempty"` +} + type apiAccountBilling struct { Customer bool `json:"customer"` Subscription bool `json:"subscription"` @@ -307,9 +316,7 @@ type apiAccountResponse struct { Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"` - Emails []string `json:"emails,omitempty"` - PrimaryEmail string `json:"primary_email,omitempty"` // The verified recovery email, if set - PendingEmails []string `json:"pending_emails,omitempty"` // Unverified addresses awaiting a magic-link click + Emails []*apiAccountEmailInfo `json:"emails,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` diff --git a/user/magic_link_test.go b/user/magic_link_test.go index 33053c05..4a2506c4 100644 --- a/user/magic_link_test.go +++ b/user/magic_link_test.go @@ -289,6 +289,53 @@ func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { }) } +func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + + // A provisioned user can verify an email (for notifications), but it must NOT become primary + _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) + require.Nil(t, err) + + emails, err := a.Emails(prov.ID) + require.Nil(t, err) + require.Equal(t, []string{"prov@example.com"}, emails) + primary, err := a.PrimaryEmail(prov.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + }) +} + +func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Provisioned users come from the config file (ProvisionEnabled), not AddUser + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + + // A reset token can be created, but consuming it must be rejected for a provisioned user + // (their password comes from the config file, like change-pass). + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) + }) +} + func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { a := newTestManager(t, newManager, PermissionDenyAll) diff --git a/user/manager.go b/user/manager.go index 4a7866c1..0f0064d5 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1648,8 +1648,9 @@ func (a *Manager) DeleteEmailVerification(userID, email string) error { // validating the token (kind + expiry), it deletes the link, adds the address to the user's // verified emails, and -- if the user has no primary email yet and the address is not already // primary on another account -- promotes the new address to primary. All mutations run in one -// transaction. A primary collision simply leaves the address verified but non-primary. Returns -// the consumed link. +// transaction. A primary collision simply leaves the address verified but non-primary. Provisioned +// users never get a primary (the recovery email is meaningless for them -- they can't reset). +// Returns the consumed link. func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { tokenHash := hashToken(rawToken) m, err := a.MagicLinkByHash(tokenHash) @@ -1659,6 +1660,10 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires { return nil, ErrMagicLinkNotFound } + u, err := a.UserByID(m.UserID) + if err != nil { + return nil, err + } err = db.ExecTx(a.db, func(tx *sql.Tx) error { // Single use: delete the link, then add the (idempotent) verified address if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { @@ -1667,6 +1672,9 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { return err } + if u.Provisioned { + return nil // Provisioned users don't get a primary (recovery) email + } // Promote to primary only if the user has none yet and the address is globally free. // We check with SELECTs rather than catching a unique violation, because Postgres aborts // the whole transaction on any constraint error (which would undo the verified-email add). @@ -1712,6 +1720,9 @@ func (a *Manager) ResetPassword(rawToken, password string) error { if err != nil { return err } + if u.Provisioned { + return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset + } hash, err := a.maybeHashPassword(password, false) if err != nil { return err diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index ecae6954..cc78f3d4 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -28,11 +28,14 @@ "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", "login_link_forgot_password": "Forgot password?", - "login_reset_dialog_title": "Reset password", - "login_reset_dialog_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.", - "login_reset_dialog_identifier_label": "Username or email", - "login_reset_dialog_button_submit": "Send reset link", - "login_reset_dialog_sent": "If an account exists, we've emailed a link to reset your password. Please check your inbox.", + "reset_password_request_title": "Reset password", + "reset_password_request_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.", + "reset_password_request_identifier_label": "Username or email", + "reset_password_request_button_submit": "Send reset link", + "reset_password_sent_title": "Check your inbox", + "reset_password_sent_description": "If an account exists, we've emailed a link to reset your password.", + "reset_password_back_to_login": "Back to sign-in", + "reset_password_disabled": "Password reset is not enabled on this server.", "reset_password_title": "Set a new password", "reset_password_form_password": "New password", "reset_password_form_confirm": "Confirm new password", @@ -242,6 +245,7 @@ "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", "account_basics_emails_primary_badge": "Primary", + "account_basics_emails_chip_actions": "Click for actions", "account_basics_emails_unverified": "unverified", "account_basics_emails_set_primary": "Set as recovery email", "account_basics_emails_delete": "Remove", @@ -249,7 +253,8 @@ "account_basics_emails_resend": "Resend verification email", "account_basics_emails_resent": "Verification email sent, check your inbox", "account_basics_emails_primary_elsewhere": "This email is the recovery email on another account", - "account_basics_emails_no_recovery_warning": "No recovery email set. You will not be able to reset your password. Add and verify an email below, then set it as your recovery email.", + "account_basics_emails_no_recovery_warning": "Add a recovery email address to ensure you can reset your password.", + "account_basics_emails_provisioned_info": "Provisioned users cannot add a recovery email address.", "account_basics_emails_dialog_title": "Add email address", "account_basics_emails_dialog_description": "Enter an email address to add it to your account. We will send a verification link to confirm it is yours.", "account_basics_emails_dialog_email_label": "Email address", @@ -267,6 +272,7 @@ "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)", "account_basics_tier_admin_suffix_no_tier": "(no tier)", "account_basics_tier_basic": "Basic", + "account_basics_tier_provisioned": "Provisioned", "account_basics_tier_free": "Free", "account_basics_tier_interval_monthly": "monthly", "account_basics_tier_interval_yearly": "annually", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index a45dce09..e9e2168e 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -2,7 +2,6 @@ import * as React from "react"; import { useContext, useState } from "react"; import { Alert, - Box, CardActions, CardContent, Chip, @@ -32,7 +31,10 @@ import { DialogContent, TextField, IconButton, + Menu, MenuItem, + ListItemIcon, + ListItemText, DialogContentText, useTheme, } from "@mui/material"; @@ -294,6 +296,7 @@ const AccountType = () => { >
{accountType} + {account.provisioned && } {account.billing?.paid_until && !account.billing?.cancel_at && ( { const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); const [snack, setSnack] = useState(""); // Non-empty shows a transient snackbar message + const [menuAnchor, setMenuAnchor] = useState(null); // Chip element the actions menu is anchored to + const [menuEmail, setMenuEmail] = useState(null); // The email the open menu acts on const labelId = "prefVerifiedEmails"; + const openMenu = (ev, email) => { + setMenuAnchor(ev.currentTarget); + setMenuEmail(email); + }; + const closeMenu = () => { + setMenuAnchor(null); + setMenuEmail(null); + }; + const runMenuAction = (fn) => { + closeMenu(); + fn(menuEmail.address); + }; + const handleDialogOpen = () => { setDialogKey((prev) => prev + 1); setDialogOpen(true); @@ -381,10 +399,12 @@ const Emails = () => { }; // runEmailAction wraps an account API call with the shared error handling (redirect on - // unauthorized, surface a message otherwise). The account list refreshes via the sync event. + // unauthorized, surface a message otherwise). On success it refetches the account so the email + // list reflects the change immediately, rather than waiting for the async sync event. const runEmailAction = async (fn, errorMessage) => { try { await fn(); + await accountApi.sync(); } catch (e) { console.log(`[Account] Email action failed`, e); if (e instanceof UnauthorizedError) { @@ -425,70 +445,82 @@ const Emails = () => { ); } - const verifiedEmails = account?.emails ?? []; - const pendingEmails = account?.pending_emails ?? []; - const primaryEmail = account?.primary_email ?? ""; - const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === ""; + const emails = account?.emails ?? []; + const verifiedEmails = emails.filter((e) => !e.pending); + const pendingEmails = emails.filter((e) => e.pending); + const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; + // Provisioned users get their password from the server config and cannot reset it, so they don't + // get the "no recovery email" nudge (the Add-email dialog explains the recovery-email limitation). + const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "" && !account?.provisioned; return ( - +
- {showNoRecoveryWarning && ( - - {t("account_basics_emails_no_recovery_warning")} - - )} - - {verifiedEmails.map((email) => { - const isPrimary = email === primaryEmail; - return ( - - {email} - {isPrimary && } - {!isPrimary && ( - - handleSetPrimary(email)}> - - - - )} - {isPrimary && } - - handleCopy(email)}> - - - - - handleDelete(email)}> - - - - - ); - })} - {pendingEmails.map((email) => ( - - - {email} ({t("account_basics_emails_unverified")}) - - - handleResend(email)}> - - + {verifiedEmails.map((email) => ( + : undefined} + label={ + + {email.address} - - handleDelete(email)}> - - + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={email.primary ? { "& .MuiChip-icon": { color: "primary.main" } } : undefined} + /> + ))} + {pendingEmails.map((email) => ( + + + {email.address} ({t("account_basics_emails_unverified")}) + - - ))} - {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} - + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={{ opacity: 0.7 }} + /> + ))} + {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} + {showNoRecoveryWarning && ( + + {t("account_basics_emails_no_recovery_warning")} + + )}
+ + runMenuAction(handleCopy)}> + + + + {t("common_copy_to_clipboard")} + + {menuEmail && !menuEmail.pending && !menuEmail.primary && !account?.provisioned && ( + runMenuAction(handleSetPrimary)}> + + + + {t("account_basics_emails_set_primary")} + + )} + {menuEmail && menuEmail.pending && ( + runMenuAction(handleResend)}> + + + + {t("account_basics_emails_resend")} + + )} + setSnack("")} message={snack} /> @@ -500,6 +532,7 @@ const Emails = () => { const AddEmailDialog = (props) => { const theme = useTheme(); const { t } = useTranslation(); + const { account } = useContext(AccountContext); const [error, setError] = useState(""); const [email, setEmail] = useState(""); const [sending, setSending] = useState(false); @@ -512,6 +545,7 @@ const AddEmailDialog = (props) => { try { setSending(true); await accountApi.startEmailVerification(email); + await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately setSent(true); } catch (e) { console.log(`[Account] Error starting email verification`, e); @@ -534,6 +568,11 @@ const AddEmailDialog = (props) => { ) : ( <> {t("account_basics_emails_dialog_description")} + {config.enable_reset_password && account?.provisioned && ( + + {t("account_basics_emails_provisioned_info")} + + )} { } /> } /> + } /> } /> } /> }> diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx index ce70a8d5..8013652e 100644 --- a/web/src/components/EmailVerify.jsx +++ b/web/src/components/EmailVerify.jsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Typography, Button, Box, CircularProgress } from "@mui/material"; import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; -import { useParams, NavLink } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; @@ -16,6 +16,7 @@ import routes from "./routes"; const EmailVerify = () => { const { t } = useTranslation(); const { token } = useParams(); + const navigate = useNavigate(); const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error" const ran = useRef(false); @@ -40,31 +41,33 @@ const EmailVerify = () => { return ( {status === "verifying" && ( - <> - + + {t("email_verify_progress_title")} - + )} {status === "success" && ( <> - - {t("email_verify_success_title")} + + + {t("email_verify_success_title")} + {t("email_verify_success_description")} - )} {status === "error" && ( <> - - {t("email_verify_error_title")} - {t("email_verify_error_description")} - - + + + {t("email_verify_error_title")} + {t("email_verify_error_description")} + )} diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index a95f9a14..9bf8e1c5 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -1,18 +1,6 @@ import * as React from "react"; import { useState } from "react"; -import { - Typography, - TextField, - Button, - Box, - IconButton, - InputAdornment, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, -} from "@mui/material"; +import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { NavLink } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -29,7 +17,6 @@ const Login = () => { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); - const [resetOpen, setResetOpen] = useState(false); const handleSubmit = async (event) => { event.preventDefault(); @@ -115,9 +102,9 @@ const Login = () => { {config.enable_reset_password && (
- +
)} {config.enable_signup && ( @@ -129,67 +116,8 @@ const Login = () => { )}
- setResetOpen(false)} /> ); }; -// ForgotPasswordDialog collects a username/email and asks the server to email a reset link. The -// response is uniform, so the dialog always shows the same "if an account exists" confirmation. -const ForgotPasswordDialog = (props) => { - const { t } = useTranslation(); - const [identifier, setIdentifier] = useState(""); - const [sending, setSending] = useState(false); - const [sent, setSent] = useState(false); - - const handleSubmit = async () => { - try { - setSending(true); - await accountApi.requestPasswordReset(identifier); - } catch (e) { - console.log(`[Login] Password reset request failed`, e); - } finally { - setSending(false); - setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe) - } - }; - - return ( - - {t("login_reset_dialog_title")} - - {sent ? ( - {t("login_reset_dialog_sent")} - ) : ( - <> - {t("login_reset_dialog_description")} - setIdentifier(ev.target.value.trim())} - fullWidth - variant="standard" - /> - - )} - - - {sent ? ( - - ) : ( - <> - - - - )} - - - ); -}; - export default Login; diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx index 0e004d04..c2a2613e 100644 --- a/web/src/components/PasswordReset.jsx +++ b/web/src/components/PasswordReset.jsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Typography, TextField, Button, Box } from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; -import { useParams, NavLink } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; @@ -15,6 +15,7 @@ import routes from "./routes"; const PasswordReset = () => { const { t } = useTranslation(); const { token: tokenParam } = useParams(); + const navigate = useNavigate(); const token = useRef(tokenParam); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); @@ -49,10 +50,12 @@ const PasswordReset = () => { if (done) { return ( - - {t("reset_password_success_title")} + + + {t("reset_password_success_title")} + {t("reset_password_success_description")} - diff --git a/web/src/components/ResetPassword.jsx b/web/src/components/ResetPassword.jsx new file mode 100644 index 00000000..05d95e21 --- /dev/null +++ b/web/src/components/ResetPassword.jsx @@ -0,0 +1,95 @@ +import * as React from "react"; +import { useState } from "react"; +import { TextField, Button, Box, Typography } from "@mui/material"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import { NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import routes from "./routes"; + +// ResetPassword is the standalone "request a password reset" page, reached from the login page. +// It collects a username/email and asks the server to email a reset link. The response is uniform, +// so the page always shows the same confirmation. Completing the reset happens on the separate +// PasswordReset landing page that the emailed link points to. +const ResetPassword = () => { + const { t } = useTranslation(); + const [identifier, setIdentifier] = useState(""); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(false); + + const handleSubmit = async (event) => { + event.preventDefault(); + try { + setSending(true); + await accountApi.requestPasswordReset(identifier); + } catch (e) { + console.log(`[ResetPassword] Request failed`, e); + } finally { + setSending(false); + setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe) + } + }; + + if (!config.enable_reset_password) { + return ( + + {t("reset_password_disabled")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + if (sent) { + return ( + + + + {t("reset_password_sent_title")} + + {t("reset_password_sent_description")} + + + {t("reset_password_back_to_login")} + + + + ); + } + + return ( + + {t("reset_password_request_title")} + + {t("reset_password_request_description")} + setIdentifier(ev.target.value.trim())} + autoFocus + /> + + + {config.enable_login && ( + + + {t("reset_password_back_to_login")} + + + )} + + ); +}; + +export default ResetPassword; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 6e649df3..463fb237 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -4,6 +4,7 @@ import { shortUrl } from "../app/utils"; const routes = { login: "/login", signup: "/signup", + resetPassword: "/reset-password", app: config.app_root, account: "/account", settings: "/settings", From f558935c1e28ee9bdfe3772e9a700451bcda5278 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 22:33:09 -0400 Subject: [PATCH 12/46] Re-wording, chips --- server/errors.go | 2 +- user/types.go | 2 +- web/public/static/langs/en.json | 19 ++++++++------- web/src/components/Account.jsx | 35 ++++++++++++++++++---------- web/src/components/EmailVerify.jsx | 4 ++-- web/src/components/PasswordReset.jsx | 2 +- web/src/components/ResetPassword.jsx | 2 +- 7 files changed, 39 insertions(+), 27 deletions(-) diff --git a/server/errors.go b/server/errors.go index 51bfbe21..d204aa53 100644 --- a/server/errors.go +++ b/server/errors.go @@ -157,7 +157,7 @@ var ( errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil} errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil} errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} - errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the recovery email on another account", "", nil} + errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil} errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} diff --git a/user/types.go b/user/types.go index 44aecd5d..ce7efe36 100644 --- a/user/types.go +++ b/user/types.go @@ -296,7 +296,7 @@ var ( ErrPhoneNumberExists = errors.New("phone number already exists") ErrEmailNotFound = errors.New("email not found") ErrEmailExists = errors.New("email already exists") - ErrEmailPrimaryElsewhere = errors.New("email is the recovery email on another account") + ErrEmailPrimaryElsewhere = errors.New("email is the primary email on another account") ErrMagicLinkNotFound = errors.New("magic link not found") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index cc78f3d4..278d6466 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -29,11 +29,11 @@ "login_link_signup": "Sign up", "login_link_forgot_password": "Forgot password?", "reset_password_request_title": "Reset password", - "reset_password_request_description": "Enter your username or email address. If an account exists, we'll email a link to reset your password.", + "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.", "reset_password_request_identifier_label": "Username or email", "reset_password_request_button_submit": "Send reset link", "reset_password_sent_title": "Check your inbox", - "reset_password_sent_description": "If an account exists, we've emailed a link to reset your password.", + "reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.", "reset_password_back_to_login": "Back to sign-in", "reset_password_disabled": "Password reset is not enabled on this server.", "reset_password_title": "Set a new password", @@ -247,16 +247,17 @@ "account_basics_emails_primary_badge": "Primary", "account_basics_emails_chip_actions": "Click for actions", "account_basics_emails_unverified": "unverified", - "account_basics_emails_set_primary": "Set as recovery email", + "account_basics_emails_set_primary": "Set as primary email", "account_basics_emails_delete": "Remove", "account_basics_emails_cancel": "Cancel", "account_basics_emails_resend": "Resend verification email", "account_basics_emails_resent": "Verification email sent, check your inbox", - "account_basics_emails_primary_elsewhere": "This email is the recovery email on another account", - "account_basics_emails_no_recovery_warning": "Add a recovery email address to ensure you can reset your password.", - "account_basics_emails_provisioned_info": "Provisioned users cannot add a recovery email address.", + "account_basics_emails_primary_elsewhere": "This email is the primary email on another account", + "account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.", + "account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.", + "account_basics_emails_provisioned_info": "Provisioned users cannot add a primary email address, but you can still add an email address for notifications.", "account_basics_emails_dialog_title": "Add email address", - "account_basics_emails_dialog_description": "Enter an email address to add it to your account. We will send a verification link to confirm it is yours.", + "account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.", "account_basics_emails_dialog_email_label": "Email address", "account_basics_emails_dialog_email_placeholder": "e.g. user@example.com", "account_basics_emails_dialog_verify_button": "Send verification link", @@ -269,8 +270,8 @@ "account_basics_tier_title": "Account type", "account_basics_tier_description": "Your account's power level", "account_basics_tier_admin": "Admin", - "account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)", - "account_basics_tier_admin_suffix_no_tier": "(no tier)", + "account_basics_tier_admin_suffix_with_tier": "with {{tier}} tier", + "account_basics_tier_admin_suffix_no_tier": "no tier", "account_basics_tier_basic": "Basic", "account_basics_tier_provisioned": "Provisioned", "account_basics_tier_free": "Free", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index e9e2168e..0024c70f 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -269,22 +269,23 @@ const AccountType = () => { } }; + // The account type is a base label ("Admin", "Basic", "Free", or the tier name) plus an optional + // qualifier chip (admin tier status, or the billing interval). let accountType; + let qualifierChip; if (account.role === Role.ADMIN) { - const tierSuffix = account.tier - ? t("account_basics_tier_admin_suffix_with_tier", { - tier: account.tier.name, - }) + accountType = t("account_basics_tier_admin"); + qualifierChip = account.tier + ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier"); - accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; } else if (!account.tier) { accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic"); } else { accountType = account.tier.name; if (account.billing?.interval === SubscriptionInterval.MONTH) { - accountType += ` (${t("account_basics_tier_interval_monthly")})`; + qualifierChip = t("account_basics_tier_interval_monthly"); } else if (account.billing?.interval === SubscriptionInterval.YEAR) { - accountType += ` (${t("account_basics_tier_interval_yearly")})`; + qualifierChip = t("account_basics_tier_interval_yearly"); } } @@ -296,6 +297,7 @@ const AccountType = () => { >
{accountType} + {qualifierChip && } {account.provisioned && } {account.billing?.paid_until && !account.billing?.cancel_at && ( { const verifiedEmails = emails.filter((e) => !e.pending); const pendingEmails = emails.filter((e) => e.pending); const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; - // Provisioned users get their password from the server config and cannot reset it, so they don't - // get the "no recovery email" nudge (the Add-email dialog explains the recovery-email limitation). - const showNoRecoveryWarning = config.enable_reset_password && primaryEmail === "" && !account?.provisioned; + // Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog + // explains the limitation): prompt for a first email when there are none, or for a primary when + // there are emails but none is primary. + const recoveryRelevant = config.enable_reset_password && !account?.provisioned; + const hasNoEmails = verifiedEmails.length === 0 && pendingEmails.length === 0; + const showNoEmailWarning = recoveryRelevant && hasNoEmails; + const showNoPrimaryWarning = recoveryRelevant && !hasNoEmails && primaryEmail === ""; return ( @@ -468,7 +474,7 @@ const Emails = () => { variant="outlined" onClick={(ev) => openMenu(ev, email)} onDelete={() => handleDelete(email.address)} - sx={email.primary ? { "& .MuiChip-icon": { color: "primary.main" } } : undefined} + sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined} /> ))} {pendingEmails.map((email) => ( @@ -491,11 +497,16 @@ const Emails = () => { - {showNoRecoveryWarning && ( + {showNoEmailWarning && ( {t("account_basics_emails_no_recovery_warning")} )} + {showNoPrimaryWarning && ( + + {t("account_basics_emails_no_primary_warning")} + + )}
runMenuAction(handleCopy)}> diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx index 8013652e..d1f09783 100644 --- a/web/src/components/EmailVerify.jsx +++ b/web/src/components/EmailVerify.jsx @@ -49,7 +49,7 @@ const EmailVerify = () => { {status === "success" && ( <> - + {t("email_verify_success_title")} {t("email_verify_success_description")} @@ -61,7 +61,7 @@ const EmailVerify = () => { {status === "error" && ( <> - + {t("email_verify_error_title")} {t("email_verify_error_description")} diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx index c2a2613e..091a22c6 100644 --- a/web/src/components/PasswordReset.jsx +++ b/web/src/components/PasswordReset.jsx @@ -51,7 +51,7 @@ const PasswordReset = () => { return ( - + {t("reset_password_success_title")} {t("reset_password_success_description")} diff --git a/web/src/components/ResetPassword.jsx b/web/src/components/ResetPassword.jsx index 05d95e21..6b765d5f 100644 --- a/web/src/components/ResetPassword.jsx +++ b/web/src/components/ResetPassword.jsx @@ -48,7 +48,7 @@ const ResetPassword = () => { return ( - + {t("reset_password_sent_title")} {t("reset_password_sent_description")} From 1215e99098e50b09bbc2c116f2a8884e5d67d2f6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 Jun 2026 23:13:01 -0400 Subject: [PATCH 13/46] Provide email during signup --- server/server_account.go | 13 ++++++++ server/server_account_email_test.go | 50 +++++++++++++++++++++++++++++ server/types.go | 1 + web/public/static/langs/en.json | 8 +++-- web/src/app/AccountApi.js | 3 +- web/src/components/Account.jsx | 15 +++++++-- web/src/components/Signup.jsx | 15 ++++++++- 7 files changed, 98 insertions(+), 7 deletions(-) diff --git a/server/server_account.go b/server/server_account.go index 214a71ba..644a9283 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -37,6 +37,9 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * if err != nil { return err } + if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) { + return errHTTPBadRequestEmailAddressInvalid + } if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil { return errHTTPConflictUserExists } @@ -48,6 +51,16 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * return err } v.AccountCreated() + // If an email was provided and email sending is configured, start verification (best-effort). + // The address becomes the primary email on verify (the new account has no primary yet); a + // failure to send must not fail signup, so we only log it. + if newAccount.Email != "" && s.mailSender != nil { + if u, err := s.userManager.User(newAccount.Username); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification") + } else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil { + logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification") + } + } return s.writeJSON(w, newSuccessResponse()) } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index 38ed8d84..1b216c12 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -276,6 +276,56 @@ func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) { }) } +func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + + // Sign up with an optional email -> account created and a verification link sent + rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + link := mailer.verifyLinks["emma@example.com"] + require.NotEmpty(t, link) + + // Verifying the link makes it the (first) primary email + token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/") + require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code) + account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")}) + require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account)) + require.Equal(t, "emma@example.com", primaryAddr(account)) + }) +} + +func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.EnableSignup = true + conf.SMTPSenderAddr = "localhost:25" + conf.SMTPSenderFrom = "noreply@example.com" + conf.BaseURL = "https://ntfy.example.com" + s := newTestServer(t, conf) + mailer := newCaptureMailer() + s.mailSender = mailer + defer s.closeDatabases() + + // No email -> account created, nothing sent + require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code) + require.Empty(t, mailer.verifyLinks) + + // Invalid email -> rejected + rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code) + }) +} + func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { hash, err := user.HashPassword("provpass") diff --git a/server/types.go b/server/types.go index 08687fbb..4b0c738d 100644 --- a/server/types.go +++ b/server/types.go @@ -185,6 +185,7 @@ type apiAccessResetRequest struct { type apiAccountCreateRequest struct { Username string `json:"username"` Password string `json:"password"` + Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent } type apiAccountPasswordChangeRequest struct { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 278d6466..6b207f2d 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -16,6 +16,7 @@ "version_update_available_description": "The ntfy server has been updated. Please refresh the page.", "signup_title": "Create a ntfy account", "signup_form_username": "Username", + "signup_form_email": "Email (optional, for account recovery)", "signup_form_password": "Password", "signup_form_confirm_password": "Confirm password", "signup_form_button_submit": "Sign up", @@ -246,13 +247,16 @@ "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", "account_basics_emails_primary_badge": "Primary", "account_basics_emails_chip_actions": "Click for actions", + "account_basics_emails_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.", + "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", + "account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.", "account_basics_emails_unverified": "unverified", "account_basics_emails_set_primary": "Set as primary email", - "account_basics_emails_delete": "Remove", + "account_basics_emails_delete": "Remove address", "account_basics_emails_cancel": "Cancel", "account_basics_emails_resend": "Resend verification email", "account_basics_emails_resent": "Verification email sent, check your inbox", - "account_basics_emails_primary_elsewhere": "This email is the primary email on another account", + "account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account", "account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.", "account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.", "account_basics_emails_provisioned_info": "Provisioned users cannot add a primary email address, but you can still add an email address for notifications.", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 823eba53..10facafd 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -69,11 +69,12 @@ class AccountApi { }); } - async create(username, password) { + async create(username, password, email) { const url = accountUrl(config.base_url); const body = JSON.stringify({ username, password, + email: email || "", }); console.log(`[AccountApi] Creating user account ${url}`); await fetchOrThrow(url, { diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 0024c70f..0134224a 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -448,7 +448,8 @@ const Emails = () => { } const emails = account?.emails ?? []; - const verifiedEmails = emails.filter((e) => !e.pending); + // Verified addresses, primary always first + const verifiedEmails = emails.filter((e) => !e.pending).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)); const pendingEmails = emails.filter((e) => e.pending); const primaryEmail = verifiedEmails.find((e) => e.primary)?.address ?? ""; // Recovery nudges (skipped for provisioned users -- they can't reset, and the Add-email dialog @@ -467,7 +468,9 @@ const Emails = () => { key={email.address} icon={email.primary ? : undefined} label={ - + {email.address} } @@ -481,7 +484,7 @@ const Emails = () => { + {email.address} ({t("account_basics_emails_unverified")}) @@ -531,6 +534,12 @@ const Emails = () => { {t("account_basics_emails_resend")} )} + runMenuAction(handleDelete)}> + + + + {t("account_basics_emails_delete")} + diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index 7da54c49..cd3a3d87 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -15,6 +15,7 @@ const Signup = () => { const { t } = useTranslation(); const [error, setError] = useState(""); const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [showPassword, setShowPassword] = useState(false); @@ -24,7 +25,7 @@ const Signup = () => { event.preventDefault(); const user = { username, password }; try { - await accountApi.create(user.username, user.password); + await accountApi.create(user.username, user.password, email); const token = await accountApi.login(user); console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); await session.store(user.username, token); @@ -64,6 +65,18 @@ const Signup = () => { onChange={(ev) => setUsername(ev.target.value.trim())} autoFocus /> + {config.enable_emails && ( + setEmail(ev.target.value.trim())} + /> + )} Date: Fri, 12 Jun 2026 23:22:37 -0400 Subject: [PATCH 14/46] Make chips look better --- web/src/components/Account.jsx | 111 +++++++++++++++++---------------- 1 file changed, 58 insertions(+), 53 deletions(-) diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 0134224a..016d97e4 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useContext, useState } from "react"; import { Alert, + Box, CardActions, CardContent, Chip, @@ -463,43 +464,45 @@ const Emails = () => { return (
- {verifiedEmails.map((email) => ( - : undefined} - label={ - - {email.address} - - } - variant="outlined" - onClick={(ev) => openMenu(ev, email)} - onDelete={() => handleDelete(email.address)} - sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined} - /> - ))} - {pendingEmails.map((email) => ( - - - {email.address} ({t("account_basics_emails_unverified")}) - - - } - variant="outlined" - onClick={(ev) => openMenu(ev, email)} - onDelete={() => handleDelete(email.address)} - sx={{ opacity: 0.7 }} - /> - ))} - {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} - - - + + {verifiedEmails.map((email) => ( + : undefined} + label={ + + {email.address} + + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={email.primary ? { "& .MuiChip-icon": { color: "#fbc02d" } } : undefined} + /> + ))} + {pendingEmails.map((email) => ( + + + {email.address} ({t("account_basics_emails_unverified")}) + + + } + variant="outlined" + onClick={(ev) => openMenu(ev, email)} + onDelete={() => handleDelete(email.address)} + sx={{ opacity: 0.7 }} + /> + ))} + {verifiedEmails.length === 0 && pendingEmails.length === 0 && {t("account_basics_emails_no_emails_yet")}} + + + + {showNoEmailWarning && ( {t("account_basics_emails_no_recovery_warning")} @@ -680,22 +683,24 @@ const PhoneNumbers = () => { return (
- {account?.phone_numbers?.map((phoneNumber) => ( - - {phoneNumber} - - } - variant="outlined" - onClick={() => handleCopy(phoneNumber)} - onDelete={() => handleDelete(phoneNumber)} - /> - ))} - {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} - - - + + {account?.phone_numbers?.map((phoneNumber) => ( + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> + ))} + {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}} + + + +
From eff808d0f8a130d01dc12d5dc09ecde90e2e8acd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 13 Jun 2026 09:35:09 -0400 Subject: [PATCH 15/46] Lint --- cmd/user.go | 3 - cmd/user_test.go | 4 +- user/magic_link_test.go | 360 ---------------------------------------- user/manager_test.go | 352 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 365 deletions(-) delete mode 100644 user/magic_link_test.go diff --git a/cmd/user.go b/cmd/user.go index 8c65221d..d9d5b0fc 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -126,8 +126,6 @@ does not require the user to have a verified primary email (the token is bound t With --send-email, the link is additionally emailed to the user's primary email address (this requires SMTP to be configured and the user to have a verified primary email). -Requires base-url to be configured so an absolute link can be generated. - Example: ntfy user reset-pass phil # Print a reset link for user phil ntfy user reset-pass --send-email phil # Print and email the reset link @@ -290,7 +288,6 @@ func execUserDel(c *cli.Context) error { func execUserChangePass(c *cli.Context) error { username := c.Args().Get(0) password, hashed := os.LookupEnv("NTFY_PASSWORD_HASH") - if !hashed { password = os.Getenv("NTFY_PASSWORD") } diff --git a/cmd/user_test.go b/cmd/user_test.go index c5e1c44c..bde0fa1f 100644 --- a/cmd/user_test.go +++ b/cmd/user_test.go @@ -126,12 +126,12 @@ func TestCLI_User_ResetPass(t *testing.T) { s, conf, port := newTestServerWithAuth(t) defer test.StopServer(t, s, port) - app, stdin, stdout, _ := newTestApp() + app, stdin, _, _ := newTestApp() stdin.WriteString("mypass\nmypass") require.Nil(t, runUserCommand(app, conf, "add", "phil")) // Prints a working-looking reset link when base-url is set - app, _, stdout, _ = newTestApp() + app, _, stdout, _ := newTestApp() require.Nil(t, runUserCommand(app, conf, "--base-url=https://ntfy.example.com", "reset-pass", "phil")) require.Contains(t, stdout.String(), "https://ntfy.example.com/account/password/reset/") } diff --git a/user/magic_link_test.go b/user/magic_link_test.go deleted file mode 100644 index 4a2506c4..00000000 --- a/user/magic_link_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package user - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// addVerifyLink stores an email-verification magic link and returns the raw token so the test -// can "click" it via VerifyEmail. -func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { - raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) - require.Nil(t, err) - return raw -} - -func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - - // Before verifying: pending, not yet verified, no primary - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, pending) - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(emails)) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - - // Verify: the first verified email auto-becomes primary - m, err := a.VerifyEmail(raw) - require.Nil(t, err) - require.Equal(t, "phil@example.com", m.Email) - - emails, err = a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, emails) - primary, err = a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "phil@example.com", primary) - pending, err = a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(pending)) - - // Reset-by-email lookup resolves to the account - userID, err := a.UserIDByPrimaryEmail("phil@example.com") - require.Nil(t, err) - require.Equal(t, phil.ID, userID) - }) -} - -func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw1) - require.Nil(t, err) - - raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw2) - require.Nil(t, err) - - // Both verified, but primary is still the first - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "first@example.com", primary) - }) -} - -func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - ben, err := a.User("ben") - require.Nil(t, err) - - // phil verifies shared@ first -> becomes his primary - _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) - require.Nil(t, err) - primary, err := a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "shared@example.com", primary) - - // ben verifies the same address -> allowed as secondary, but NOT his primary - _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) - require.Nil(t, err) - emails, err := a.Emails(ben.ID) - require.Nil(t, err) - require.Equal(t, []string{"shared@example.com"}, emails) - primary, err = a.PrimaryEmail(ben.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - - // Explicitly promoting ben's copy to primary collides with phil's - require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) - // ...and phil keeps his primary (the failed promotion rolled back ben's clear) - primary, err = a.PrimaryEmail(phil.ID) - require.Nil(t, err) - require.Equal(t, "shared@example.com", primary) - }) -} - -func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) - }) -} - -func TestUser_MagicLink_Expired(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) - _, err = a.VerifyEmail(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Nothing got verified - emails, err := a.Emails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(emails)) - }) -} - -func TestUser_MagicLink_SingleUse(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - _, err = a.VerifyEmail(raw) - require.Nil(t, err) - // Second click: token already consumed - _, err = a.VerifyEmail(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) - - // Only one pending row remains; the old token no longer works - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, []string{"phil@example.com"}, pending) - _, err = a.MagicLinkByToken(raw1) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - m, err := a.MagicLinkByToken(raw2) - require.Nil(t, err) - require.Equal(t, "phil@example.com", m.Email) - }) -} - -func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - - m, err := a.MagicLinkByToken(raw) - require.Nil(t, err) - require.Equal(t, MagicLinkKindPasswordReset, m.Kind) - require.Equal(t, phil.ID, m.UserID) - require.Equal(t, "", m.Email) // reset rows carry no email - - // Reset rows do not appear as pending emails - pending, err := a.PendingEmails(phil.ID) - require.Nil(t, err) - require.Equal(t, 0, len(pending)) - - // New request replaces the old token - raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - _, err = a.MagicLinkByToken(raw) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Single use: deleting consumes it - require.Nil(t, a.DeleteMagicLinkByToken(raw2)) - _, err = a.MagicLinkByToken(raw2) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_Reaper(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) - valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) - - require.Nil(t, a.deleteExpiredMagicLinks()) - - _, err = a.MagicLinkByToken(expired) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - m, err := a.MagicLinkByToken(valid) - require.Nil(t, err) - require.Equal(t, "valid@example.com", m.Email) - }) -} - -func TestUser_MagicLink_ResetPassword(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - - // Old password works before reset - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - - require.Nil(t, a.ResetPassword(raw, "newpass")) - - // New password works, old does not - _, err = a.Authenticate("phil", "newpass") - require.Nil(t, err) - _, err = a.Authenticate("phil", "oldpass") - require.ErrorIs(t, err, ErrUnauthenticated) - - // Token is single-use - require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) - }) -} - -func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - // An email-verification token must not be usable for password reset... - verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) - require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) - - // ...and a reset token must not be usable for email verification - resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) - require.Nil(t, err) - _, err = a.VerifyEmail(resetToken) - require.ErrorIs(t, err, ErrMagicLinkNotFound) - - // Old password unchanged - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - }) -} - -func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManagerFromConfig(t, newManager, &Config{ - DefaultAccess: PermissionDenyAll, - ProvisionEnabled: true, - Users: []*User{ - {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - }, - }) - prov, err := a.User("prov") - require.Nil(t, err) - - // A provisioned user can verify an email (for notifications), but it must NOT become primary - _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) - require.Nil(t, err) - - emails, err := a.Emails(prov.ID) - require.Nil(t, err) - require.Equal(t, []string{"prov@example.com"}, emails) - primary, err := a.PrimaryEmail(prov.ID) - require.Nil(t, err) - require.Equal(t, "", primary) - }) -} - -func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - // Provisioned users come from the config file (ProvisionEnabled), not AddUser - a := newTestManagerFromConfig(t, newManager, &Config{ - DefaultAccess: PermissionDenyAll, - ProvisionEnabled: true, - Users: []*User{ - {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, - }, - }) - prov, err := a.User("prov") - require.Nil(t, err) - require.True(t, prov.Provisioned) - - // A reset token can be created, but consuming it must be rejected for a provisioned user - // (their password comes from the config file, like change-pass). - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) - require.Nil(t, err) - require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) - }) -} - -func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) - phil, err := a.User("phil") - require.Nil(t, err) - - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) - require.Nil(t, err) - require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) - _, err = a.Authenticate("phil", "oldpass") - require.Nil(t, err) - }) -} - -func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { - forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { - a := newTestManager(t, newManager, PermissionDenyAll) - _, err := a.UserIDByPrimaryEmail("ghost@example.com") - require.ErrorIs(t, err, ErrUserNotFound) - }) -} diff --git a/user/manager_test.go b/user/manager_test.go index 7e05f5db..6bc0bccd 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2897,3 +2897,355 @@ func TestStoreOtherAccessCount(t *testing.T) { require.Equal(t, 2, count) // ben's owner entry + everyone entry }) } + +// addVerifyLink stores an email-verification magic link and returns the raw token so the test +// can "click" it via VerifyEmail. +func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { + raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) + require.Nil(t, err) + return raw +} + +func TestUser_MagicLink_VerifyEmail_SetsPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Before verifying: pending, not yet verified, no primary + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Verify: the first verified email auto-becomes primary + m, err := a.VerifyEmail(raw) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + + emails, err = a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, emails) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "phil@example.com", primary) + pending, err = a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // Reset-by-email lookup resolves to the account + userID, err := a.UserIDByPrimaryEmail("phil@example.com") + require.Nil(t, err) + require.Equal(t, phil.ID, userID) + }) +} + +func TestUser_MagicLink_VerifyEmail_SecondStaysSecondary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "first@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw1) + require.Nil(t, err) + + raw2 := addVerifyLink(t, a, phil.ID, "second@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw2) + require.Nil(t, err) + + // Both verified, but primary is still the first + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"first@example.com", "second@example.com"}, emails) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "first@example.com", primary) + }) +} + +func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + + // phil verifies shared@ first -> becomes his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + primary, err := a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + + // ben verifies the same address -> allowed as secondary, but NOT his primary + _, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour)) + require.Nil(t, err) + emails, err := a.Emails(ben.ID) + require.Nil(t, err) + require.Equal(t, []string{"shared@example.com"}, emails) + primary, err = a.PrimaryEmail(ben.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + + // Explicitly promoting ben's copy to primary collides with phil's + require.ErrorIs(t, a.SetPrimaryEmail(ben.ID, "shared@example.com"), ErrEmailPrimaryElsewhere) + // ...and phil keeps his primary (the failed promotion rolled back ben's clear) + primary, err = a.PrimaryEmail(phil.ID) + require.Nil(t, err) + require.Equal(t, "shared@example.com", primary) + }) +} + +func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + require.ErrorIs(t, a.SetPrimaryEmail(phil.ID, "nope@example.com"), ErrEmailNotFound) + }) +} + +func TestUser_MagicLink_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", -time.Minute) + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Nothing got verified + emails, err := a.Emails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(emails)) + }) +} + +func TestUser_MagicLink_SingleUse(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + _, err = a.VerifyEmail(raw) + require.Nil(t, err) + // Second click: token already consumed + _, err = a.VerifyEmail(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ReplaceOnReRequest(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw1 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + raw2 := addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour) + + // Only one pending row remains; the old token no longer works + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, []string{"phil@example.com"}, pending) + _, err = a.MagicLinkByToken(raw1) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + m, err := a.MagicLinkByToken(raw2) + require.Nil(t, err) + require.Equal(t, "phil@example.com", m.Email) + }) +} + +func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + m, err := a.MagicLinkByToken(raw) + require.Nil(t, err) + require.Equal(t, MagicLinkKindPasswordReset, m.Kind) + require.Equal(t, phil.ID, m.UserID) + require.Equal(t, "", m.Email) // reset rows carry no email + + // Reset rows do not appear as pending emails + pending, err := a.PendingEmails(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(pending)) + + // New request replaces the old token + raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.MagicLinkByToken(raw) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Single use: deleting consumes it + require.Nil(t, a.DeleteMagicLinkByToken(raw2)) + _, err = a.MagicLinkByToken(raw2) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_Reaper(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) + + require.Nil(t, a.deleteExpiredMagicLinks()) + + _, err = a.MagicLinkByToken(expired) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + m, err := a.MagicLinkByToken(valid) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + +func TestUser_MagicLink_ResetPassword(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + + // Old password works before reset + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + + require.Nil(t, a.ResetPassword(raw, "newpass")) + + // New password works, old does not + _, err = a.Authenticate("phil", "newpass") + require.Nil(t, err) + _, err = a.Authenticate("phil", "oldpass") + require.ErrorIs(t, err, ErrUnauthenticated) + + // Token is single-use + require.ErrorIs(t, a.ResetPassword(raw, "againpass"), ErrMagicLinkNotFound) + }) +} + +func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + // An email-verification token must not be usable for password reset... + verifyToken := addVerifyLink(t, a, phil.ID, "phil@example.com", time.Hour) + require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) + + // ...and a reset token must not be usable for email verification + resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + require.Nil(t, err) + _, err = a.VerifyEmail(resetToken) + require.ErrorIs(t, err, ErrMagicLinkNotFound) + + // Old password unchanged + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + + // A provisioned user can verify an email (for notifications), but it must NOT become primary + _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) + require.Nil(t, err) + + emails, err := a.Emails(prov.ID) + require.Nil(t, err) + require.Equal(t, []string{"prov@example.com"}, emails) + primary, err := a.PrimaryEmail(prov.ID) + require.Nil(t, err) + require.Equal(t, "", primary) + }) +} + +func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + // Provisioned users come from the config file (ProvisionEnabled), not AddUser + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + ProvisionEnabled: true, + Users: []*User{ + {Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser}, + }, + }) + prov, err := a.User("prov") + require.Nil(t, err) + require.True(t, prov.Provisioned) + + // A reset token can be created, but consuming it must be rejected for a provisioned user + // (their password comes from the config file, like change-pass). + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) + }) +} + +func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.Nil(t, a.AddUser("phil", "oldpass", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + require.Nil(t, err) + require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) + _, err = a.Authenticate("phil", "oldpass") + require.Nil(t, err) + }) +} + +func TestUser_MagicLink_UserIDByPrimaryEmail_NotFound(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + _, err := a.UserIDByPrimaryEmail("ghost@example.com") + require.ErrorIs(t, err, ErrUserNotFound) + }) +} From 9fa8550ef67bf59c77f32a94d6756713c90a6dc4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 13 Jun 2026 13:42:32 -0400 Subject: [PATCH 16/46] Remove Close() on Sender; words on password dialog --- mail/sender.go | 6 ------ server/errors.go | 2 +- server/server.go | 3 --- server/server_account.go | 4 ++-- server/server_account_email_test.go | 2 -- server/smtp_sender.go | 1 - web/public/static/langs/en.json | 2 +- 7 files changed, 4 insertions(+), 16 deletions(-) diff --git a/mail/sender.go b/mail/sender.go index b840adf1..3215c332 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -36,12 +36,6 @@ func NewSender(config *Config) *Sender { return &Sender{config: config} } -// Close is a no-op, kept so callers don't need to special-case the sender. The sender holds -// no background goroutines (magic-link expiry is swept by the user.Manager reaper). -func (s *Sender) Close() { - // Nothing to do -} - // Addr returns the SMTP server address func (s *Sender) Addr() string { return s.config.SMTPAddr diff --git a/server/errors.go b/server/errors.go index d204aa53..6a7bd769 100644 --- a/server/errors.go +++ b/server/errors.go @@ -143,7 +143,7 @@ var ( errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} - errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} + errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil} diff --git a/server/server.go b/server/server.go index 3184478a..4cbc7984 100644 --- a/server/server.go +++ b/server/server.go @@ -452,9 +452,6 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } - if s.mailSender != nil { - s.mailSender.Close() - } if s.attachment != nil { s.attachment.Close() } diff --git a/server/server_account.go b/server/server_account.go index 644a9283..621fe63b 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -690,11 +690,11 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request if err != nil { return err } else if req.Token == "" { - return errHTTPBadRequestEmailVerificationCodeInvalid + return errHTTPBadRequestEmailVerificationLinkInvalid } m, err := s.userManager.VerifyEmail(req.Token) if errors.Is(err, user.ErrMagicLinkNotFound) { - return errHTTPBadRequestEmailVerificationCodeInvalid + return errHTTPBadRequestEmailVerificationLinkInvalid } else if err != nil { return err } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index 1b216c12..eb66ba2c 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -32,8 +32,6 @@ func (c *captureMailer) SendPasswordReset(to, link string) error { return nil } -func (c *captureMailer) Close() {} - // newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) // and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 1e7460e8..199cecd9 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -25,7 +25,6 @@ type mailer interface { type emailVerifier interface { SendEmailVerification(to, link string) error SendPasswordReset(to, link string) error - Close() } type smtpSender struct { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 6b207f2d..e8926227 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -30,7 +30,7 @@ "login_link_signup": "Sign up", "login_link_forgot_password": "Forgot password?", "reset_password_request_title": "Reset password", - "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.", + "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed. This only works if you already added a primary email address and verified it.", "reset_password_request_identifier_label": "Username or email", "reset_password_request_button_submit": "Send reset link", "reset_password_sent_title": "Check your inbox", From 5834f667b29019f29e3811353ccb65a2e0ee3ca5 Mon Sep 17 00:00:00 2001 From: cyvnrs Date: Mon, 15 Jun 2026 11:25:58 +0200 Subject: [PATCH 17/46] Added translation using Weblate (Telugu) --- web/public/static/langs/te.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/te.json diff --git a/web/public/static/langs/te.json b/web/public/static/langs/te.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/te.json @@ -0,0 +1 @@ +{} From fc59339f86445728b847248bbe7b19a6bfb0f6d5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 Jun 2026 21:47:09 -0400 Subject: [PATCH 18/46] Rename --- server/server.go | 94 ++++++++++++++-------------- server/server_account.go | 8 +-- server/server_account_email_test.go | 12 ++-- server/server_account_test.go | 2 +- server/server_manager.go | 4 +- server/server_middleware.go | 2 +- server/server_payments.go | 2 +- server/server_payments_email_test.go | 2 +- server/server_test.go | 26 ++++---- server/smtp_sender.go | 18 +++--- 10 files changed, 87 insertions(+), 83 deletions(-) diff --git a/server/server.go b/server/server.go index 4cbc7984..ade5aebe 100644 --- a/server/server.go +++ b/server/server.go @@ -48,31 +48,31 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { - config *Config - db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite - httpServer *http.Server - httpsServer *http.Server - httpMetricsServer *http.Server - httpProfileServer *http.Server - unixListener net.Listener - smtpServer *smtp.Server - smtpServerBackend *smtpBackend - smtpSender mailer - mailSender emailVerifier - topics map[string]*topic - visitors map[string]*visitor // ip: or user: - firebaseClient *firebaseClient - messages int64 // Total number of messages (persisted if messageCache enabled) - messagesHistory []int64 // Last n values of the messages counter, used to determine rate - userManager *user.Manager // Might be nil! - messageCache *message.Cache // Database that stores the messages - webPush *webpush.Store // Database that stores web push subscriptions - attachment *attachment.Store // Attachment store (file system or S3) - 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 + config *Config + db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite + httpServer *http.Server + httpsServer *http.Server + httpMetricsServer *http.Server + httpProfileServer *http.Server + unixListener net.Listener + smtpServer *smtp.Server + smtpServerBackend *smtpBackend + notificationMailer messageMailer + accountMailer magicLinkMailer + topics map[string]*topic + visitors map[string]*visitor // ip: or user: + firebaseClient *firebaseClient + messages int64 // Total number of messages (persisted if messageCache enabled) + messagesHistory []int64 // Last n values of the messages counter, used to determine rate + userManager *user.Manager // Might be nil! + messageCache *message.Cache // Database that stores the messages + webPush *webpush.Store // Database that stores web push subscriptions + attachment *attachment.Store // Attachment store (file system or S3) + 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 } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -184,17 +184,17 @@ const ( // New instantiates a new Server. It creates the cache and adds a Firebase // subscriber (if configured). func New(conf *Config) (*Server, error) { - var mailer mailer - var emailSender emailVerifier // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly + var notificationMailer messageMailer + var accountEmailer magicLinkMailer // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly if conf.SMTPSenderAddr != "" { - mailSender := mail.NewSender(&mail.Config{ + sender := mail.NewSender(&mail.Config{ SMTPAddr: conf.SMTPSenderAddr, SMTPUser: conf.SMTPSenderUser, SMTPPass: conf.SMTPSenderPass, From: conf.SMTPSenderFrom, }) - mailer = &smtpSender{config: conf, sender: mailSender} - emailSender = mailSender + notificationMailer = ¬ificationSender{config: conf, sender: sender} + accountEmailer = sender } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -293,20 +293,20 @@ func New(conf *Config) (*Server, error) { firebaseClient = newFirebaseClient(sender, auther) } s := &Server{ - config: conf, - db: pool, - messageCache: messageCache, - webPush: wp, - attachment: attachmentStore, - firebaseClient: firebaseClient, - smtpSender: mailer, - mailSender: emailSender, - topics: topics, - userManager: userManager, - messages: messages, - messagesHistory: []int64{messages}, - visitors: make(map[string]*visitor), - stripe: stripe, + config: conf, + db: pool, + messageCache: messageCache, + webPush: wp, + attachment: attachmentStore, + firebaseClient: firebaseClient, + notificationMailer: notificationMailer, + accountMailer: accountEmailer, + topics: topics, + userManager: userManager, + messages: messages, + messagesHistory: []int64{messages}, + visitors: make(map[string]*visitor), + stripe: stripe, } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) return s, nil @@ -974,7 +974,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) } - if s.smtpSender != nil && email != "" { + if s.notificationMailer != nil && email != "" { go s.sendEmail(v, m, email) } if s.config.TwilioAccount != "" && call != "" { @@ -1136,7 +1136,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email) - if err := s.smtpSender.Send(v, m, email); err != nil { + if err := s.notificationMailer.Send(v, m, email); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) minc(metricEmailsPublishedFailure) return @@ -1236,7 +1236,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) { return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid } - if s.smtpSender == nil && email != "" { + if s.notificationMailer == nil && email != "" { return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") diff --git a/server/server_account.go b/server/server_account.go index 621fe63b..79c43866 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -54,7 +54,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * // If an email was provided and email sending is configured, start verification (best-effort). // The address becomes the primary email on verify (the new account has no primary yet); a // failure to send must not fail signup, so we only log it. - if newAccount.Email != "" && s.mailSender != nil { + if newAccount.Email != "" && s.accountMailer != nil { if u, err := s.userManager.User(newAccount.Username); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification") } else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil { @@ -175,7 +175,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis response.PhoneNumbers = phoneNumbers } } - if s.mailSender != nil { + if s.accountMailer != nil { emails, err := s.userManager.Emails(u.ID) if err != nil { return err @@ -788,7 +788,7 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { return err } link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token - return s.mailSender.SendEmailVerification(email, link) + return s.accountMailer.SendEmailVerification(email, link) } // handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, @@ -814,7 +814,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt } else { link := s.config.BaseURL + webAppPasswordResetPathPrefix + token logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") - if err := s.mailSender.SendPasswordReset(email, link); err != nil { + if err := s.accountMailer.SendPasswordReset(email, link); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") } } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index eb66ba2c..f2ca2d3a 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -11,7 +11,7 @@ import ( "heckel.io/ntfy/v2/util" ) -// captureMailer is a fake emailVerifier that records the magic links it is asked to send, so +// captureMailer is a fake magicLinkMailer that records the magic links it is asked to send, so // tests can "click" them without a real SMTP server. type captureMailer struct { verifyLinks map[string]string // email -> verification link @@ -41,7 +41,7 @@ func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMail conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} return s, mailer, auth @@ -283,7 +283,7 @@ func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer defer s.closeDatabases() // Sign up with an optional email -> account created and a verification link sent @@ -310,7 +310,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer defer s.closeDatabases() // No email -> account created, nothing sent @@ -335,7 +335,7 @@ func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} s := newTestServer(t, conf) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer defer s.closeDatabases() auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} @@ -364,7 +364,7 @@ func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { } s := newTestServer(t, conf) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer defer s.closeDatabases() // Give the provisioned user a verified primary email anyway diff --git a/server/server_account_test.go b/server/server_account_test.go index f2f36168..b74aac0d 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) { conf.VisitorAttachmentTotalSizeLimit = 5123 conf.AttachmentFileSizeLimit = 512 s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} defer s.closeDatabases() rr := request(t, s, "GET", "/v1/account", "", nil) diff --git a/server/server_manager.go b/server/server_manager.go index 387ad2b8..e2fb66e9 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -54,8 +54,8 @@ func (s *Server) execManager() { receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() } var sentMailTotal, sentMailSuccess, sentMailFailure int64 - if s.smtpSender != nil { - sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts() + if s.notificationMailer != nil { + sentMailTotal, sentMailSuccess, sentMailFailure = s.notificationMailer.Counts() } // Users diff --git a/server/server_middleware.go b/server/server_middleware.go index b8e650c5..7f76ae4c 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.mailSender == nil || s.userManager == nil { + if s.accountMailer == nil || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_payments.go b/server/server_payments.go index 4b19a091..56a0026b 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -252,7 +252,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr // collision (or any other skip), the generic "no recovery email set" warning on the account page // nudges the user to add one. This is best-effort: failures are logged, never surfaced. func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { - if s.mailSender == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + if s.accountMailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { return } emails, err := s.userManager.Emails(userID) diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go index 24fa7bb2..ff7727da 100644 --- a/server/server_payments_email_test.go +++ b/server/server_payments_email_test.go @@ -45,7 +45,7 @@ func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *cap c.SMTPSenderFrom = "noreply@example.com" s := newTestServer(t, c) mailer := newCaptureMailer() - s.mailSender = mailer + s.accountMailer = mailer require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, })) diff --git a/server/server_test.go b/server/server_test.go index 1d19815b..04a4a8da 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -740,7 +740,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { func TestServer_PublishInvalidTopic(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) }) @@ -1231,7 +1231,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} // Publish some messages, and check stats for i := 0; i < 3; i++ { @@ -1461,7 +1461,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1481,7 +1481,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { c := newTestConfig(t, databaseURL) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1509,7 +1509,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", "Delay": "20 min", @@ -1546,7 +1546,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { func TestServer_PublishEmailAddressInvalid(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} addresses := []string{ "test@example.com, other@example.com", "invalidaddress", @@ -1572,7 +1572,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1602,7 +1602,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1631,7 +1631,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} // "yes" without smtp-sender-verify should fail with invalid address response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -1647,7 +1647,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} defer s.closeDatabases() // Anonymous user should be rejected @@ -1664,7 +1664,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1682,7 +1682,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = &testMailer{} + s.notificationMailer = &testMailer{} // Without smtp-sender-verify, any email address should work (backwards compatible) response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -2139,7 +2139,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) { t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t, databaseURL)) - s.smtpSender = mailer + s.notificationMailer = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 199cecd9..9ef7c000 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -15,19 +15,23 @@ import ( "heckel.io/ntfy/v2/util" ) -type mailer interface { +// messageMailer sends notification emails (the email-on-publish feature). It formats a ntfy +// message into an email. Implemented by *notificationSender; tests inject testMailer. +type messageMailer interface { Send(v *visitor, m *model.Message, to string) error Counts() (total int64, success int64, failure int64) } -// emailVerifier sends the magic-link emails for email verification and password reset. +// magicLinkMailer sends the magic-link emails for email verification and password reset. // *mail.Sender implements it; tests inject a fake to capture the generated links. -type emailVerifier interface { +type magicLinkMailer interface { SendEmailVerification(to, link string) error SendPasswordReset(to, link string) error } -type smtpSender struct { +// notificationSender adapts a *mail.Sender for notification emails: it formats a model.Message +// into an email and tracks success/failure counts. +type notificationSender struct { config *Config sender *mail.Sender success int64 @@ -35,7 +39,7 @@ type smtpSender struct { mu sync.Mutex } -func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { +func (s *notificationSender) Send(v *visitor, m *model.Message, to string) error { return s.withCount(v, m, func() error { message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m) if err != nil { @@ -56,13 +60,13 @@ func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { }) } -func (s *smtpSender) Counts() (total int64, success int64, failure int64) { +func (s *notificationSender) Counts() (total int64, success int64, failure int64) { s.mu.Lock() defer s.mu.Unlock() return s.success + s.failure, s.success, s.failure } -func (s *smtpSender) withCount(v *visitor, m *model.Message, fn func() error) error { +func (s *notificationSender) withCount(v *visitor, m *model.Message, fn func() error) error { err := fn() s.mu.Lock() defer s.mu.Unlock() From 44d5bcf8751ce563316441866a2311d2bff6a54f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 Jun 2026 22:21:22 -0400 Subject: [PATCH 19/46] Fix mail mess --- mail/format.go | 95 +++++++++++ .../format_test.go | 2 +- {server => mail}/mailer_emoji_map.json | 0 mail/sender.go | 137 +++++++++------ server/server.go | 90 +++++----- server/server_account.go | 8 +- server/server_account_email_test.go | 23 ++- server/server_account_test.go | 2 +- server/server_manager.go | 4 +- server/server_middleware.go | 2 +- server/server_payments.go | 2 +- server/server_payments_email_test.go | 2 +- server/server_test.go | 32 ++-- server/smtp_sender.go | 160 ------------------ 14 files changed, 268 insertions(+), 291 deletions(-) create mode 100644 mail/format.go rename server/smtp_sender_test.go => mail/format_test.go (99%) rename {server => mail}/mailer_emoji_map.json (100%) delete mode 100644 server/smtp_sender.go diff --git a/mail/format.go b/mail/format.go new file mode 100644 index 00000000..e68ec8ea --- /dev/null +++ b/mail/format.go @@ -0,0 +1,95 @@ +package mail + +import ( + _ "embed" // required by go:embed + "encoding/json" + "fmt" + "mime" + "strings" + "time" + + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/util" +) + +var ( + //go:embed "mailer_emoji_map.json" + emojisJSON string + + // emojiMap maps ntfy tag names to emoji, parsed once from the embedded JSON in init + emojiMap map[string]string +) + +func init() { + if err := json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { + panic("mail: invalid embedded emoji map: " + err.Error()) + } +} + +func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { + topicURL := baseURL + "/" + m.Topic + subject := m.Title + if subject == "" { + subject = m.Message + } + subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ") + message := m.Message + trailer := "" + if len(m.Tags) > 0 { + emojis, tags := toEmojis(m.Tags) + if len(emojis) > 0 { + subject = strings.Join(emojis, " ") + " " + subject + } + if len(tags) > 0 { + trailer = "Tags: " + strings.Join(tags, ", ") + } + } + if m.Priority != 0 && m.Priority != 3 { + priority, err := util.PriorityString(m.Priority) + if err != nil { + return "", err + } + if trailer != "" { + trailer += "\n" + } + trailer += fmt.Sprintf("Priority: %s", priority) + } + if trailer != "" { + message += "\n\n" + trailer + } + date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z) + subject = mime.BEncoding.Encode("utf-8", subject) + body := `From: "{shortTopicURL}" <{from}> +To: {to} +Date: {date} +Subject: {subject} +Content-Type: text/plain; charset="utf-8" + +{message} + +-- +This message was sent by {ip} at {time} via {topicURL}` + body = strings.ReplaceAll(body, "{from}", from) + body = strings.ReplaceAll(body, "{to}", to) + body = strings.ReplaceAll(body, "{date}", date) + body = strings.ReplaceAll(body, "{subject}", subject) + body = strings.ReplaceAll(body, "{message}", message) + body = strings.ReplaceAll(body, "{topicURL}", topicURL) + body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL)) + body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123)) + body = strings.ReplaceAll(body, "{ip}", senderIP) + return body, nil +} + +func toEmojis(tags []string) (emojisOut []string, tagsOut []string) { + tagsOut = make([]string, 0) + emojisOut = make([]string, 0) + for _, t := range tags { + if emoji, ok := emojiMap[t]; ok { + emojisOut = append(emojisOut, emoji) + } else { + tagsOut = append(tagsOut, t) + } + } + return +} diff --git a/server/smtp_sender_test.go b/mail/format_test.go similarity index 99% rename from server/smtp_sender_test.go rename to mail/format_test.go index 4f97b128..4c4c4613 100644 --- a/server/smtp_sender_test.go +++ b/mail/format_test.go @@ -1,4 +1,4 @@ -package server +package mail import ( "testing" diff --git a/server/mailer_emoji_map.json b/mail/mailer_emoji_map.json similarity index 100% rename from server/mailer_emoji_map.json rename to mail/mailer_emoji_map.json diff --git a/mail/sender.go b/mail/sender.go index 3215c332..5463285f 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -6,66 +6,98 @@ import ( "net" "net/smtp" "strings" + "sync" "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/model" ) const ( + tagMail = "mail" + emailVerificationSubject = "Verify your email for ntfy" passwordResetSubject = "Reset your ntfy password" ) // Config holds the SMTP configuration for the mail sender type Config struct { + BaseURL string // ntfy base URL, used to build topic URLs in notification emails SMTPAddr string // SMTP server address (host:port) SMTPUser string // SMTP auth username SMTPPass string // SMTP auth password From string // Sender email address } -// Sender sends emails via SMTP, including the magic-link emails for email verification and -// password reset. Pending verification/reset state lives in the database (see user.Manager), -// not in this struct. -type Sender struct { - config *Config +// Sender sends all of ntfy's outgoing email: notification emails (the email-on-publish feature) +// as well as the magic-link emails for email verification and password reset. realSender is the +// SMTP-backed implementation; tests inject a fake. +type Sender interface { + SendNotification(to string, m *model.Message, senderIP string) error + Counts() (total int64, success int64, failure int64) + SendEmailVerification(to, link string) error + SendPasswordReset(to, link string) error +} + +// realSender is the SMTP-backed implementation of Sender. Pending verification/reset state lives +// in the database (see user.Manager), not in this struct. +type realSender struct { + config *Config + success int64 + failure int64 + mu sync.Mutex } // NewSender creates a new mail Sender with the given SMTP config -func NewSender(config *Config) *Sender { - return &Sender{config: config} +func NewSender(config *Config) Sender { + return &realSender{config: config} } -// Addr returns the SMTP server address -func (s *Sender) Addr() string { - return s.config.SMTPAddr -} - -// User returns the SMTP username -func (s *Sender) User() string { - return s.config.SMTPUser -} - -// From returns the sender email address -func (s *Sender) From() string { - return s.config.From -} - -// SendRaw sends a raw email message via SMTP -func (s *Sender) SendRaw(to string, message []byte) error { - host, _, err := net.SplitHostPort(s.config.SMTPAddr) +// SendNotification formats a ntfy message into a notification email and sends it via SMTP. It +// tracks success/failure counts, exposed via Counts (used for the server stats). +func (s *realSender) SendNotification(to string, m *model.Message, senderIP string) error { + message, err := formatMail(s.config.BaseURL, senderIP, s.config.From, to, m) if err != nil { + s.count(false) return err } - var auth smtp.Auth - if s.config.SMTPUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) - } - return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) + log.Tag(tagMail).Field("email_to", to).Debug("Sending notification email") + err = s.sendRaw(to, []byte(message)) + s.count(err == nil) + return err } -// Send sends a plain text email via SMTP -func (s *Sender) Send(to, subject, body string) error { +// Counts returns the number of notification emails sent, broken down into total, success and failure +func (s *realSender) Counts() (total int64, success int64, failure int64) { + s.mu.Lock() + defer s.mu.Unlock() + return s.success + s.failure, s.success, s.failure +} + +// SendEmailVerification sends an email containing a magic link to verify ownership of the +// recipient address. The link carries a one-time token validated against the database. +func (s *realSender) SendEmailVerification(to, link string) error { + body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: + +%s + +This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) + return s.send(to, emailVerificationSubject, body) +} + +// SendPasswordReset sends an email containing a magic link to set a new password. The link +// carries a one-time token validated against the database. +func (s *realSender) SendPasswordReset(to, link string) error { + body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: + +%s + +This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) + return s.send(to, passwordResetSubject, body) +} + +// send sends a plain text email via SMTP +func (s *realSender) send(to, subject, body string) error { date := time.Now().UTC().Format(time.RFC1123Z) encodedSubject := mime.BEncoding.Encode("utf-8", subject) message := `From: ntfy <{from}> @@ -80,28 +112,29 @@ Content-Type: text/plain; charset="utf-8" message = strings.ReplaceAll(message, "{date}", date) message = strings.ReplaceAll(message, "{subject}", encodedSubject) message = strings.ReplaceAll(message, "{body}", body) - log.Tag("mail").Field("email_to", to).Debug("Sending email") - return s.SendRaw(to, []byte(message)) + log.Tag(tagMail).Field("email_to", to).Debug("Sending email") + return s.sendRaw(to, []byte(message)) } -// SendEmailVerification sends an email containing a magic link to verify ownership of the -// recipient address. The link carries a one-time token validated against the database. -func (s *Sender) SendEmailVerification(to, link string) error { - body := fmt.Sprintf(`Click the link below to verify this email address for your ntfy account: - -%s - -This link expires in 24 hours. If you did not request this, you can safely ignore this email.`, link) - return s.Send(to, emailVerificationSubject, body) +// sendRaw sends a raw email message via SMTP +func (s *realSender) sendRaw(to string, message []byte) error { + host, _, err := net.SplitHostPort(s.config.SMTPAddr) + if err != nil { + return err + } + var auth smtp.Auth + if s.config.SMTPUser != "" { + auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host) + } + return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message) } -// SendPasswordReset sends an email containing a magic link to set a new password. The link -// carries a one-time token validated against the database. -func (s *Sender) SendPasswordReset(to, link string) error { - body := fmt.Sprintf(`Click the link below to set a new password for your ntfy account: - -%s - -This link expires in 1 hour. If you did not request this, you can safely ignore this email -- your password will not change.`, link) - return s.Send(to, passwordResetSubject, body) +func (s *realSender) count(ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + if ok { + s.success++ + } else { + s.failure++ + } } diff --git a/server/server.go b/server/server.go index ade5aebe..db338a22 100644 --- a/server/server.go +++ b/server/server.go @@ -48,31 +48,30 @@ import ( // Server is the main server, providing the UI and API for ntfy type Server struct { - config *Config - db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite - httpServer *http.Server - httpsServer *http.Server - httpMetricsServer *http.Server - httpProfileServer *http.Server - unixListener net.Listener - smtpServer *smtp.Server - smtpServerBackend *smtpBackend - notificationMailer messageMailer - accountMailer magicLinkMailer - topics map[string]*topic - visitors map[string]*visitor // ip: or user: - firebaseClient *firebaseClient - messages int64 // Total number of messages (persisted if messageCache enabled) - messagesHistory []int64 // Last n values of the messages counter, used to determine rate - userManager *user.Manager // Might be nil! - messageCache *message.Cache // Database that stores the messages - webPush *webpush.Store // Database that stores web push subscriptions - attachment *attachment.Store // Attachment store (file system or S3) - 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 + config *Config + db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite + httpServer *http.Server + httpsServer *http.Server + httpMetricsServer *http.Server + httpProfileServer *http.Server + unixListener net.Listener + smtpServer *smtp.Server + smtpServerBackend *smtpBackend + mailer mail.Sender + topics map[string]*topic + visitors map[string]*visitor // ip: or user: + firebaseClient *firebaseClient + messages int64 // Total number of messages (persisted if messageCache enabled) + messagesHistory []int64 // Last n values of the messages counter, used to determine rate + userManager *user.Manager // Might be nil! + messageCache *message.Cache // Database that stores the messages + webPush *webpush.Store // Database that stores web push subscriptions + attachment *attachment.Store // Attachment store (file system or S3) + 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 } // handleFunc extends the normal http.HandlerFunc to be able to easily return errors @@ -184,17 +183,15 @@ const ( // New instantiates a new Server. It creates the cache and adds a Firebase // subscriber (if configured). func New(conf *Config) (*Server, error) { - var notificationMailer messageMailer - var accountEmailer magicLinkMailer // Stays untyped-nil when SMTP is unconfigured, so ensureEmailsEnabled gates correctly + var sender mail.Sender if conf.SMTPSenderAddr != "" { - sender := mail.NewSender(&mail.Config{ + sender = mail.NewSender(&mail.Config{ + BaseURL: conf.BaseURL, SMTPAddr: conf.SMTPSenderAddr, SMTPUser: conf.SMTPSenderUser, SMTPPass: conf.SMTPSenderPass, From: conf.SMTPSenderFrom, }) - notificationMailer = ¬ificationSender{config: conf, sender: sender} - accountEmailer = sender } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -293,20 +290,19 @@ func New(conf *Config) (*Server, error) { firebaseClient = newFirebaseClient(sender, auther) } s := &Server{ - config: conf, - db: pool, - messageCache: messageCache, - webPush: wp, - attachment: attachmentStore, - firebaseClient: firebaseClient, - notificationMailer: notificationMailer, - accountMailer: accountEmailer, - topics: topics, - userManager: userManager, - messages: messages, - messagesHistory: []int64{messages}, - visitors: make(map[string]*visitor), - stripe: stripe, + config: conf, + db: pool, + messageCache: messageCache, + webPush: wp, + attachment: attachmentStore, + firebaseClient: firebaseClient, + mailer: sender, + topics: topics, + userManager: userManager, + messages: messages, + messagesHistory: []int64{messages}, + visitors: make(map[string]*visitor), + stripe: stripe, } s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration) return s, nil @@ -974,7 +970,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess if s.firebaseClient != nil && firebase { go s.sendToFirebase(v, m) } - if s.notificationMailer != nil && email != "" { + if s.mailer != nil && email != "" { go s.sendEmail(v, m, email) } if s.config.TwilioAccount != "" && call != "" { @@ -1136,7 +1132,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email) - if err := s.notificationMailer.Send(v, m, email); err != nil { + if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) minc(metricEmailsPublishedFailure) return @@ -1236,7 +1232,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) { return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid } - if s.notificationMailer == nil && email != "" { + if s.mailer == nil && email != "" { return false, false, "", "", "", false, "", errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") diff --git a/server/server_account.go b/server/server_account.go index 79c43866..c7ba5335 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -54,7 +54,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * // If an email was provided and email sending is configured, start verification (best-effort). // The address becomes the primary email on verify (the new account has no primary yet); a // failure to send must not fail signup, so we only log it. - if newAccount.Email != "" && s.accountMailer != nil { + if newAccount.Email != "" && s.mailer != nil { if u, err := s.userManager.User(newAccount.Username); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification") } else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil { @@ -175,7 +175,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis response.PhoneNumbers = phoneNumbers } } - if s.accountMailer != nil { + if s.mailer != nil { emails, err := s.userManager.Emails(u.ID) if err != nil { return err @@ -788,7 +788,7 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { return err } link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token - return s.accountMailer.SendEmailVerification(email, link) + return s.mailer.SendEmailVerification(email, link) } // handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request, @@ -814,7 +814,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt } else { link := s.config.BaseURL + webAppPasswordResetPathPrefix + token logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link") - if err := s.accountMailer.SendPasswordReset(email, link); err != nil { + if err := s.mailer.SendPasswordReset(email, link); err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email") } } diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index f2ca2d3a..a71d5979 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -7,12 +7,13 @@ import ( "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) -// captureMailer is a fake magicLinkMailer that records the magic links it is asked to send, so -// tests can "click" them without a real SMTP server. +// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can +// "click" them without a real SMTP server. The notification side is a no-op. type captureMailer struct { verifyLinks map[string]string // email -> verification link resetLinks map[string]string // email -> reset link @@ -32,6 +33,14 @@ func (c *captureMailer) SendPasswordReset(to, link string) error { return nil } +func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error { + return nil +} + +func (c *captureMailer) Counts() (total int64, success int64, failure int64) { + return 0, 0, 0 +} + // newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured) // and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth. func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) { @@ -41,7 +50,7 @@ func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMail conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")} return s, mailer, auth @@ -283,7 +292,7 @@ func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // Sign up with an optional email -> account created and a verification link sent @@ -310,7 +319,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { conf.BaseURL = "https://ntfy.example.com" s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // No email -> account created, nothing sent @@ -335,7 +344,7 @@ func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} @@ -364,7 +373,7 @@ func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) { } s := newTestServer(t, conf) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer defer s.closeDatabases() // Give the provisioned user a verified primary email anyway diff --git a/server/server_account_test.go b/server/server_account_test.go index b74aac0d..3c4f1787 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,7 +151,7 @@ func TestAccount_Get_Anonymous(t *testing.T) { conf.VisitorAttachmentTotalSizeLimit = 5123 conf.AttachmentFileSizeLimit = 512 s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() rr := request(t, s, "GET", "/v1/account", "", nil) diff --git a/server/server_manager.go b/server/server_manager.go index e2fb66e9..51a46078 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -54,8 +54,8 @@ func (s *Server) execManager() { receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts() } var sentMailTotal, sentMailSuccess, sentMailFailure int64 - if s.notificationMailer != nil { - sentMailTotal, sentMailSuccess, sentMailFailure = s.notificationMailer.Counts() + if s.mailer != nil { + sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.Counts() } // Users diff --git a/server/server_middleware.go b/server/server_middleware.go index 7f76ae4c..cc77b6df 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -105,7 +105,7 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.accountMailer == nil || s.userManager == nil { + if s.mailer == nil || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_payments.go b/server/server_payments.go index 56a0026b..76216bbe 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -252,7 +252,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr // collision (or any other skip), the generic "no recovery email set" warning on the account page // nudges the user to add one. This is best-effort: failures are logged, never surfaced. func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) { - if s.accountMailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { + if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) { return } emails, err := s.userManager.Emails(userID) diff --git a/server/server_payments_email_test.go b/server/server_payments_email_test.go index ff7727da..42a7aa43 100644 --- a/server/server_payments_email_test.go +++ b/server/server_payments_email_test.go @@ -45,7 +45,7 @@ func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *cap c.SMTPSenderFrom = "noreply@example.com" s := newTestServer(t, c) mailer := newCaptureMailer() - s.accountMailer = mailer + s.mailer = mailer require.Nil(t, s.userManager.AddTier(&user.Tier{ ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour, })) diff --git a/server/server_test.go b/server/server_test.go index 04a4a8da..ea47dacb 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -740,7 +740,7 @@ func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) { func TestServer_PublishInvalidTopic(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/docs", "fail", nil) require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) }) @@ -1231,7 +1231,7 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) { c := newTestConfigWithAuthFile(t, databaseURL) s := newTestServer(t, c) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // Publish some messages, and check stats for i := 0; i < 3; i++ { @@ -1319,7 +1319,7 @@ type testMailer struct { mu sync.Mutex } -func (t *testMailer) Send(v *visitor, m *model.Message, to string) error { +func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ @@ -1336,6 +1336,10 @@ func (t *testMailer) Count() int { return t.count } +func (t *testMailer) SendEmailVerification(to, link string) error { return nil } + +func (t *testMailer) SendPasswordReset(to, link string) error { return nil } + func TestServer_PublishTooManyRequests_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) @@ -1461,7 +1465,7 @@ func TestServer_PublishTooManyRequests_ShortReplenish(t *testing.T) { func TestServer_PublishTooManyEmails_Defaults(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1481,7 +1485,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { c := newTestConfig(t, databaseURL) c.VisitorEmailLimitReplenish = 500 * time.Millisecond s := newTestServer(t, c) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} for i := 0; i < 16; i++ { response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{ "E-Mail": "test@example.com", @@ -1509,7 +1513,7 @@ func TestServer_PublishTooManyEmails_Replenish(t *testing.T) { func TestServer_PublishDelayedEmail_Fail(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "E-Mail": "test@example.com", "Delay": "20 min", @@ -1546,7 +1550,7 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { func TestServer_PublishEmailAddressInvalid(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} addresses := []string{ "test@example.com, other@example.com", "invalidaddress", @@ -1572,7 +1576,7 @@ func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1602,7 +1606,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1631,7 +1635,7 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // "yes" without smtp-sender-verify should fail with invalid address response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -1647,7 +1651,7 @@ func TestServer_PublishEmailVerify_Anonymous(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() // Anonymous user should be rejected @@ -1664,7 +1668,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderVerify = true s := newTestServer(t, conf) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) @@ -1682,7 +1686,7 @@ func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) { func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = &testMailer{} + s.mailer = &testMailer{} // Without smtp-sender-verify, any email address should work (backwards compatible) response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ @@ -2139,7 +2143,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) { t.Parallel() mailer := &testMailer{} s := newTestServer(t, newTestConfig(t, databaseURL)) - s.notificationMailer = mailer + s.mailer = mailer body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}` response := request(t, s, "PUT", "/", body, nil) require.Equal(t, 200, response.Code) diff --git a/server/smtp_sender.go b/server/smtp_sender.go deleted file mode 100644 index 9ef7c000..00000000 --- a/server/smtp_sender.go +++ /dev/null @@ -1,160 +0,0 @@ -package server - -import ( - _ "embed" // required by go:embed - "encoding/json" - "fmt" - "mime" - "strings" - "sync" - "time" - - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/mail" - "heckel.io/ntfy/v2/model" - "heckel.io/ntfy/v2/util" -) - -// messageMailer sends notification emails (the email-on-publish feature). It formats a ntfy -// message into an email. Implemented by *notificationSender; tests inject testMailer. -type messageMailer interface { - Send(v *visitor, m *model.Message, to string) error - Counts() (total int64, success int64, failure int64) -} - -// magicLinkMailer sends the magic-link emails for email verification and password reset. -// *mail.Sender implements it; tests inject a fake to capture the generated links. -type magicLinkMailer interface { - SendEmailVerification(to, link string) error - SendPasswordReset(to, link string) error -} - -// notificationSender adapts a *mail.Sender for notification emails: it formats a model.Message -// into an email and tracks success/failure counts. -type notificationSender struct { - config *Config - sender *mail.Sender - success int64 - failure int64 - mu sync.Mutex -} - -func (s *notificationSender) Send(v *visitor, m *model.Message, to string) error { - return s.withCount(v, m, func() error { - message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m) - if err != nil { - return err - } - ev := logvm(v, m). - Tag(tagEmail). - Fields(log.Context{ - "email_via": s.sender.Addr(), - "email_user": s.sender.User(), - "email_to": to, - }) - if ev.IsTrace() { - ev.Field("email_body", message).Trace("Sending email") - } - ev.Info("Sending email") - return s.sender.SendRaw(to, []byte(message)) - }) -} - -func (s *notificationSender) Counts() (total int64, success int64, failure int64) { - s.mu.Lock() - defer s.mu.Unlock() - return s.success + s.failure, s.success, s.failure -} - -func (s *notificationSender) withCount(v *visitor, m *model.Message, fn func() error) error { - err := fn() - s.mu.Lock() - defer s.mu.Unlock() - if err != nil { - logvm(v, m).Err(err).Debug("Sending mail failed") - s.failure++ - } else { - s.success++ - } - return err -} - -func formatMail(baseURL, senderIP, from, to string, m *model.Message) (string, error) { - topicURL := baseURL + "/" + m.Topic - subject := m.Title - if subject == "" { - subject = m.Message - } - subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ") - message := m.Message - trailer := "" - if len(m.Tags) > 0 { - emojis, tags, err := toEmojis(m.Tags) - if err != nil { - return "", err - } - if len(emojis) > 0 { - subject = strings.Join(emojis, " ") + " " + subject - } - if len(tags) > 0 { - trailer = "Tags: " + strings.Join(tags, ", ") - } - } - if m.Priority != 0 && m.Priority != 3 { - priority, err := util.PriorityString(m.Priority) - if err != nil { - return "", err - } - if trailer != "" { - trailer += "\n" - } - trailer += fmt.Sprintf("Priority: %s", priority) - } - if trailer != "" { - message += "\n\n" + trailer - } - date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z) - subject = mime.BEncoding.Encode("utf-8", subject) - body := `From: "{shortTopicURL}" <{from}> -To: {to} -Date: {date} -Subject: {subject} -Content-Type: text/plain; charset="utf-8" - -{message} - --- -This message was sent by {ip} at {time} via {topicURL}` - body = strings.ReplaceAll(body, "{from}", from) - body = strings.ReplaceAll(body, "{to}", to) - body = strings.ReplaceAll(body, "{date}", date) - body = strings.ReplaceAll(body, "{subject}", subject) - body = strings.ReplaceAll(body, "{message}", message) - body = strings.ReplaceAll(body, "{topicURL}", topicURL) - body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL)) - body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123)) - body = strings.ReplaceAll(body, "{ip}", senderIP) - return body, nil -} - -var ( - //go:embed "mailer_emoji_map.json" - emojisJSON string -) - -func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) { - var emojiMap map[string]string - if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil { - return nil, nil, err - } - tagsOut = make([]string, 0) - emojisOut = make([]string, 0) - for _, t := range tags { - if emoji, ok := emojiMap[t]; ok { - emojisOut = append(emojisOut, emoji) - } else { - tagsOut = append(tagsOut, t) - } - } - return -} From b75d0e582c403ee20d712150530c8c8d256ad44c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 Jun 2026 22:33:30 -0400 Subject: [PATCH 20/46] Rename --- mail/sender.go | 6 ++--- server/server.go | 41 +++++++++++++++-------------- server/server_account_email_test.go | 2 +- server/server_manager.go | 2 +- server/server_test.go | 2 +- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/mail/sender.go b/mail/sender.go index 5463285f..7e0c4063 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -34,7 +34,7 @@ type Config struct { // SMTP-backed implementation; tests inject a fake. type Sender interface { SendNotification(to string, m *model.Message, senderIP string) error - Counts() (total int64, success int64, failure int64) + NotificationCounts() (total int64, success int64, failure int64) SendEmailVerification(to, link string) error SendPasswordReset(to, link string) error } @@ -67,8 +67,8 @@ func (s *realSender) SendNotification(to string, m *model.Message, senderIP stri return err } -// Counts returns the number of notification emails sent, broken down into total, success and failure -func (s *realSender) Counts() (total int64, success int64, failure int64) { +// NotificationCounts returns the number of notification emails sent, broken down into total, success and failure +func (s *realSender) NotificationCounts() (total int64, success int64, failure int64) { s.mu.Lock() defer s.mu.Unlock() return s.success + s.failure, s.success, s.failure diff --git a/server/server.go b/server/server.go index db338a22..de9c21b1 100644 --- a/server/server.go +++ b/server/server.go @@ -79,23 +79,26 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error var ( // If changed, don't forget to update Android App and auth_sqlite.go - topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! - topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! - externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic - webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) - webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app) - jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) - ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) - rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) - wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) - authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) - publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) - updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) - clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) - sequenceIDRegex = topicRegex + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /! + topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app! + externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic + jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`) + ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`) + rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`) + wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`) + authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`) + publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) + updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`) + clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`) + sequenceIDRegex = topicRegex + + webAppConfigPath = "/config.js" + webAppManifestPath = "/manifest.webmanifest" + webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended + webAppEmailVerifyRegex = regexp.MustCompile(`^/account/email/verify/[-_A-Za-z0-9]+$`) // Magic-link landing (served by the web app) + webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended + webAppPasswordResetRegex = regexp.MustCompile(`^/account/password/reset/[-_A-Za-z0-9]+$`) // Password-reset landing (served by the web app) - webConfigPath = "/config.js" - webManifestPath = "/manifest.webmanifest" accountPath = "/account" matrixPushPath = "/_matrix/push/v1/notify" metricsPath = "/metrics" @@ -121,8 +124,6 @@ var ( apiAccountEmailResendPath = "/v1/account/email/resend" apiAccountPasswordResetRequestPath = "/v1/account/password/reset/request" apiAccountPasswordResetPath = "/v1/account/password/reset" - webAppEmailVerifyPathPrefix = "/account/email/verify/" // Browser landing route; raw token appended - webAppPasswordResetPathPrefix = "/account/password/reset/" // Browser landing route; raw token appended apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -553,9 +554,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureAdmin(s.handleVersion)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath { return s.handleConfig(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { + } else if r.Method == http.MethodGet && r.URL.Path == webAppConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webManifestPath { + } else if r.Method == http.MethodGet && r.URL.Path == webAppManifestPath { return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath { return s.ensureAdmin(s.handleUsersGet)(w, r, v) diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index a71d5979..e5168cfe 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -37,7 +37,7 @@ func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP s return nil } -func (c *captureMailer) Counts() (total int64, success int64, failure int64) { +func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) { return 0, 0, 0 } diff --git a/server/server_manager.go b/server/server_manager.go index 51a46078..f9334d7e 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -55,7 +55,7 @@ func (s *Server) execManager() { } var sentMailTotal, sentMailSuccess, sentMailFailure int64 if s.mailer != nil { - sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.Counts() + sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts() } // Users diff --git a/server/server_test.go b/server/server_test.go index ea47dacb..646cd393 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1326,7 +1326,7 @@ func (t *testMailer) SendNotification(to string, m *model.Message, senderIP stri return nil } -func (t *testMailer) Counts() (total int64, success int64, failure int64) { +func (t *testMailer) NotificationCounts() (total int64, success int64, failure int64) { return 0, 0, 0 } From d8c87d04e755ba9349a9d8785cbeaa2518db7c7c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 Jun 2026 20:56:22 -0400 Subject: [PATCH 21/46] Update privacy policy, code review --- docs/privacy.md | 14 ++++++++------ server/errors.go | 2 +- server/server_account.go | 12 ++++++------ server/visitor.go | 12 +++++++----- user/manager.go | 6 +++--- web/public/static/langs/en.json | 2 +- web/src/app/errors.js | 10 +++++----- web/src/components/Signup.jsx | 4 ++-- 8 files changed, 33 insertions(+), 29 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index 055e3a35..322e4f34 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy policy -**Last updated:** March 31, 2026 +**Last updated:** June 15, 2026 This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). @@ -19,8 +19,9 @@ If you create an account on ntfy.sh, we collect: - **Username** - A unique identifier you choose - **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) -- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified - email address for use with the email notification feature +- **Email address** - If you add an email address to your account for account recovery and password resets, for use + with the email notification feature, or if you subscribe to a paid plan (for billing purposes via Stripe). Email + addresses you add to your account are verified by sending a confirmation link. - **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) You can use ntfy without creating an account. Anonymous usage is fully supported. @@ -77,9 +78,10 @@ Your phone number is shared with Twilio to deliver these services. Twilio's ### Amazon SES (email delivery) -If you use the email notification feature (`X-Email` header), we use Amazon Simple Email Service (SES) to -deliver emails. The recipient email address and message content are transmitted through Amazon's infrastructure. -Amazon's [privacy policy](https://aws.amazon.com/privacy/) applies. +If you use the email notification feature (`X-Email` header), or when ntfy sends account-related emails (email +address verification and password reset links), we use Amazon Simple Email Service (SES) to deliver emails. The +recipient email address and message content are transmitted through Amazon's infrastructure. Amazon's +[privacy policy](https://aws.amazon.com/privacy/) applies. ### Stripe (payments) diff --git a/server/errors.go b/server/errors.go index 6a7bd769..caf1abc0 100644 --- a/server/errors.go +++ b/server/errors.go @@ -167,7 +167,7 @@ var ( errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit diff --git a/server/server_account.go b/server/server_account.go index c7ba5335..517fb938 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -29,8 +29,8 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } else if u != nil { return errHTTPUnauthorized // Cannot create account from user context } - if !v.AccountCreationAllowed() { - return errHTTPTooManyRequestsLimitAccountCreation + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions } } newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false) @@ -50,7 +50,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v * } return err } - v.AccountCreated() + v.AccountActionPerformed() // If an email was provided and email sending is configured, start verification (best-effort). // The address becomes the primary email on verify (the new account has no primary yet); a // failure to send must not fail signup, so we only log it. @@ -801,10 +801,10 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt return err } // Rate limit via the shared per-visitor account-creation bucket (no new limiter/config) - if !v.AccountCreationAllowed() { - return errHTTPTooManyRequestsLimitAccountCreation + if !v.AccountActionAllowed() { + return errHTTPTooManyRequestsLimitAccountActions } - v.AccountCreated() // Consume a token on every request (including no-match), to throttle probing + v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing identifier := strings.TrimSpace(req.Identifier) if identifier != "" && s.config.BaseURL != "" { if userID, email, ok := s.resolveResetTarget(identifier); ok { diff --git a/server/visitor.go b/server/visitor.go index 3d4622dd..2f07d273 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -66,7 +66,7 @@ type visitor struct { subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads - accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil + accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil firebase time.Time // Next allowed Firebase message seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) @@ -280,8 +280,9 @@ func (v *visitor) AuthFailed() { } } -// AccountCreationAllowed returns true if a new account can be created -func (v *visitor) AccountCreationAllowed() bool { +// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset +// request) is currently allowed for this visitor +func (v *visitor) AccountActionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) { @@ -290,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool { return true } -// AccountCreated decreases the account limiter. This is to be called after an account was created. -func (v *visitor) AccountCreated() { +// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited +// account action (signup or password-reset request). +func (v *visitor) AccountActionPerformed() { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() if v.accountLimiter != nil { diff --git a/user/manager.go b/user/manager.go index 0f0064d5..40b61647 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1561,10 +1561,10 @@ func (a *Manager) SetPrimaryEmail(userID, email string) error { // emailed link. Only the hash is persisted; the raw token is never stored. email is the // address being verified for email_verify, and "" for password_reset. func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { - raw := generateLinkToken() + token := generateLinkToken() now := time.Now() m := &MagicLink{ - TokenHash: hashToken(raw), + TokenHash: hashToken(token), Kind: kind, UserID: userID, Email: email, @@ -1574,7 +1574,7 @@ func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl if err := a.AddMagicLink(m); err != nil { return "", err } - return raw, nil + return token, nil } // MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index e8926227..0e1ce85f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -28,7 +28,7 @@ "login_title": "Sign in to your ntfy account", "login_form_button_submit": "Sign in", "login_link_signup": "Sign up", - "login_link_forgot_password": "Forgot password?", + "login_link_forgot_password": "Forgot password", "reset_password_request_title": "Reset password", "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed. This only works if you already added a primary email address and verified it.", "reset_password_request_identifier_label": "Username or email", diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 5b749d14..34c86f8d 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -31,11 +31,11 @@ export class TopicReservedError extends Error { } } -export class AccountCreateLimitReachedError extends Error { - static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation +export class AccountActionLimitReachedError extends Error { + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountActions constructor() { - super("Account creation limit reached"); + super("Account action limit reached"); } } @@ -75,8 +75,8 @@ export const throwAppError = async (response) => { throw new UserExistsError(); } else if (error.code === TopicReservedError.CODE) { throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); + } else if (error.code === AccountActionLimitReachedError.CODE) { + throw new AccountActionLimitReachedError(); } else if (error.code === IncorrectPasswordError.CODE) { throw new IncorrectPasswordError(); } else if (error.code === EmailVerificationCodeInvalidError.CODE) { diff --git a/web/src/components/Signup.jsx b/web/src/components/Signup.jsx index cd3a3d87..8dcd4ea2 100644 --- a/web/src/components/Signup.jsx +++ b/web/src/components/Signup.jsx @@ -9,7 +9,7 @@ import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import session from "../app/Session"; import routes from "./routes"; -import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; +import { AccountActionLimitReachedError, UserExistsError } from "../app/errors"; const Signup = () => { const { t } = useTranslation(); @@ -34,7 +34,7 @@ const Signup = () => { console.log(`[Signup] Signup for user ${user.username} failed`, e); if (e instanceof UserExistsError) { setError(t("signup_error_username_taken", { username: e.username })); - } else if (e instanceof AccountCreateLimitReachedError) { + } else if (e instanceof AccountActionLimitReachedError) { setError(t("signup_error_creation_limit_reached")); } else { setError(e.message); From 99bc80327140d680fcad0ceb3cd95a29e1f5c320 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 Jun 2026 21:18:58 -0400 Subject: [PATCH 22/46] Code review --- server/errors.go | 2 +- server/server_account.go | 23 ++++++++++----------- server/server_account_email_test.go | 31 +++++++++++++++++++++++++++++ user/manager.go | 13 ++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/server/errors.go b/server/errors.go index caf1abc0..0b0f790d 100644 --- a/server/errors.go +++ b/server/errors.go @@ -167,7 +167,7 @@ var ( errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit diff --git a/server/server_account.go b/server/server_account.go index 517fb938..c1556cf1 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -825,21 +825,20 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt return s.writeJSON(w, newSuccessResponse()) } -// resolveResetTarget resolves a reset identifier to a single account and its primary email. -// The identifier is tried first as a username, then as a primary email address. It returns -// ok=false if no account with a primary email matches (reset requires a verified primary email). +// resolveResetTarget resolves a reset identifier (username or primary email) to a single account +// and its primary email. It applies the reset policy on top of the lookup: provisioned users are +// excluded, and ok=false is returned unless the account has a verified primary email (reset +// requires one, and that is where the link is sent). func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) { - if u, err := s.userManager.User(identifier); err == nil && u != nil && !u.Provisioned { - if primary, perr := s.userManager.PrimaryEmail(u.ID); perr == nil && primary != "" { - return u.ID, primary, true - } + u, err := s.userManager.UserByEmailOrUsername(identifier) + if err != nil || u == nil || u.Provisioned { + return "", "", false } - if uid, err := s.userManager.UserIDByPrimaryEmail(identifier); err == nil { - if u, uerr := s.userManager.UserByID(uid); uerr == nil && !u.Provisioned { - return uid, identifier, true - } + primary, err := s.userManager.PrimaryEmail(u.ID) + if err != nil || primary == "" { + return "", "", false } - return "", "", false + return u.ID, primary, true } // handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated): diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index e5168cfe..9bbcccd5 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -259,6 +259,37 @@ func TestAccount_PasswordReset_ByEmail(t *testing.T) { }) } +func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s, mailer, auth := newEmailTestServer(t, databaseURL) + defer s.closeDatabases() + + // Account A (the email owner): user "ben" with verified primary email "phil@example.com" + verifyEmailFor(t, s, mailer, auth, "phil@example.com") + + // Account B (the squatter): a different account whose USERNAME looks like A's email, with + // its own, different verified primary email + require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false)) + squatter, err := s.userManager.User("phil@example.com") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com")) + + // Reset by the ambiguous identifier: the verified email must win over the look-alike username + rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil) + require.Equal(t, 200, rr.Code) + require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A) + require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B) + + // The token resets account A (ben); the squatter's password is untouched + token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/") + rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil) + require.Equal(t, 200, rr.Code) + require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset + require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected + }) +} + func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s, mailer, _ := newEmailTestServer(t, databaseURL) diff --git a/user/manager.go b/user/manager.go index 40b61647..1c20367c 100644 --- a/user/manager.go +++ b/user/manager.go @@ -515,6 +515,19 @@ func (a *Manager) UserByID(id string) (*User, error) { return a.readUser(rows) } +// UserByEmailOrUsername resolves an identifier to a single user, trying it first as a primary +// email address and then as a username. A verified, owned email takes precedence over a +// freely-chosen username, so a look-alike username cannot shadow the email's real owner. Returns +// ErrUserNotFound if neither matches. +func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) { + if userID, err := a.UserIDByPrimaryEmail(identifier); err == nil { + if u, err := a.UserByID(userID); err == nil { + return u, nil + } + } + return a.User(identifier) +} + // userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise func (a *Manager) userByToken(token string) (*User, error) { rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) From 01eabb288acaa960dc6b3e8247caa60935478118 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 Jun 2026 21:35:03 -0400 Subject: [PATCH 23/46] Review --- server/server.go | 27 +++++++++++++-------------- server/server_test.go | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/server/server.go b/server/server.go index de9c21b1..d79d7ad0 100644 --- a/server/server.go +++ b/server/server.go @@ -545,7 +545,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { - return s.ensureWebEnabled(s.handleRoot)(w, r, v) + return s.ensureWebEnabled(s.handleWebApp)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath { @@ -671,27 +671,27 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v) } else if r.Method == http.MethodGet && (webAppEmailVerifyRegex.MatchString(r.URL.Path) || webAppPasswordResetRegex.MatchString(r.URL.Path)) { - return s.ensureWebEnabled(s.handleWebAppIndex)(w, r, v) // Magic-link landing pages (client-side routes) + return s.ensureWebEnabled(s.handleWebAppNoIndex)(w, r, v) // Magic-link landing pages (client-side routes) } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { return s.ensureWebEnabled(s.handleTopic)(w, r, v) } return errHTTPNotFound } -// handleWebAppIndex serves the embedded web app's index for client-side (SPA) routes the -// browser router resolves, such as the magic-link landing pages. Because these URLs carry a -// one-time token in the path, the response is marked no-referrer (so the token can't leak to -// third parties via the Referer header) and noindex (so it never gets indexed). -func (s *Server) handleWebAppIndex(w http.ResponseWriter, r *http.Request, v *visitor) error { - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("X-Robots-Tag", "noindex") +// handleWebApp serves the embedded web app's index for client-side (SPA) routes that the +// browser router resolves, so the app shell loads and the client-side router takes over. +func (s *Server) handleWebApp(w http.ResponseWriter, r *http.Request, v *visitor) error { r.URL.Path = webAppIndex return s.handleStatic(w, r, v) } -func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request, v *visitor) error { - r.URL.Path = webAppIndex - return s.handleStatic(w, r, v) +// handleWebAppNoIndex serves the web app index for the magic-link landing pages, whose path +// carries a one-time token. The response is marked no-referrer (so the token can't leak to third +// parties via the Referer header) and noindex (so it never gets indexed). +func (s *Server) handleWebAppNoIndex(w http.ResponseWriter, r *http.Request, v *visitor) error { + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Robots-Tag", "noindex") + return s.handleWebApp(w, r, v) } func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -702,8 +702,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) _, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n") return err } - r.URL.Path = webAppIndex - return s.handleStatic(w, r, v) + return s.handleWebApp(w, r, v) } func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { diff --git a/server/server_test.go b/server/server_test.go index 646cd393..eec41e03 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -264,6 +264,27 @@ func TestServer_StaticSites(t *testing.T) { }) } +func TestServer_WebApp_MagicLinkLandingPagesNoIndexHeaders(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfig(t, databaseURL)) + + // Magic-link landing pages carry a one-time token in the path, so the response must not + // leak the token via the Referer header and must not be indexed + for _, path := range []string{"/account/email/verify/sometoken", "/account/password/reset/sometoken"} { + rr := request(t, s, "GET", path, "", nil) + require.Equal(t, 200, rr.Code, path) + require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy"), path) + require.Equal(t, "noindex", rr.Header().Get("X-Robots-Tag"), path) + } + + // Ordinary web app routes do not set these headers + rr := request(t, s, "GET", "/", "", nil) + require.Equal(t, 200, rr.Code) + require.Empty(t, rr.Header().Get("Referrer-Policy")) + require.Empty(t, rr.Header().Get("X-Robots-Tag")) + }) +} + func TestServer_WebEnabled(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { conf := newTestConfig(t, databaseURL) From 19af0b65ccf59f92dbfa2b06a8cc87355db8a717 Mon Sep 17 00:00:00 2001 From: Sven Skender Date: Wed, 17 Jun 2026 14:52:38 +0200 Subject: [PATCH 24/46] Preserve the original S3 endpoint scheme --- s3/client_test.go | 8 ++++++++ s3/types.go | 11 ++++++++--- s3/util.go | 8 ++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/s3/client_test.go b/s3/client_test.go index d15e75ab..de754cf7 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -22,6 +22,7 @@ func TestParseURL_Success(t *testing.T) { require.Equal(t, "us-east-1", cfg.Region) require.Equal(t, "AKID", cfg.AccessKey) require.Equal(t, "SECRET", cfg.SecretKey) + require.Equal(t, "https", cfg.Scheme) require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint) require.False(t, cfg.PathStyle) } @@ -38,6 +39,7 @@ func TestParseURL_WithEndpoint(t *testing.T) { require.Nil(t, err) require.Equal(t, "my-bucket", cfg.Bucket) require.Equal(t, "prefix", cfg.Prefix) + require.Equal(t, "https", cfg.Scheme) require.Equal(t, "s3.example.com", cfg.Endpoint) require.True(t, cfg.PathStyle) } @@ -45,6 +47,7 @@ func TestParseURL_WithEndpoint(t *testing.T) { func TestParseURL_EndpointHTTP(t *testing.T) { cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000") require.Nil(t, err) + require.Equal(t, "http", cfg.Scheme) require.Equal(t, "localhost:9000", cfg.Endpoint) require.True(t, cfg.PathStyle) } @@ -111,6 +114,11 @@ func TestConfig_BucketURL_PathStyle(t *testing.T) { require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL()) } +func TestConfig_BucketURL_PathStyle_EndpointHTTP(t *testing.T) { + c := &Config{Scheme: "http", Endpoint: "localhost:9000", Bucket: "b", PathStyle: true} + require.Equal(t, "http://localhost:9000/b", c.BucketURL()) +} + func TestConfig_BucketURL_VirtualHosted(t *testing.T) { c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL()) diff --git a/s3/types.go b/s3/types.go index 094a96d3..677721b6 100644 --- a/s3/types.go +++ b/s3/types.go @@ -11,6 +11,7 @@ import ( // Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. type Config struct { + Scheme string // URL scheme, e.g. "https" or "http" Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" PathStyle bool Bucket string @@ -24,10 +25,14 @@ type Config struct { // BucketURL returns the base URL for bucket-level operations. func (c *Config) BucketURL() string { - if c.PathStyle { - return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket) + scheme := "https" + if c.Scheme != "" { + scheme = c.Scheme } - return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint) + if c.PathStyle { + return fmt.Sprintf("%s://%s/%s", scheme, c.Endpoint, c.Bucket) + } + return fmt.Sprintf("%s://%s.%s", scheme, c.Bucket, c.Endpoint) } // HostHeader returns the value for the Host header. diff --git a/s3/util.go b/s3/util.go index 64f0f7c7..25dbe09f 100644 --- a/s3/util.go +++ b/s3/util.go @@ -70,21 +70,29 @@ func ParseURL(s3URL string) (*Config, error) { return nil, fmt.Errorf("s3: region query parameter is required") } endpointParam := u.Query().Get("endpoint") + var scheme string var endpoint string var pathStyle bool if endpointParam != "" { // Custom endpoint: strip scheme prefix to extract host[:port] + uep, err := url.Parse(endpointParam) + if err != nil { + return nil, fmt.Errorf("s3: invalid endpoint URL: %w", err) + } + scheme = uep.Scheme ep := strings.TrimRight(endpointParam, "/") ep = strings.TrimPrefix(ep, "https://") ep = strings.TrimPrefix(ep, "http://") endpoint = ep pathStyle = true } else { + scheme = "https" endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) pathStyle = false } disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2")) return &Config{ + Scheme: scheme, Endpoint: endpoint, PathStyle: pathStyle, Bucket: bucket, From cac3b2986ad3501c99c402a5a987459fba1d4ec7 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Wed, 17 Jun 2026 11:58:29 -0700 Subject: [PATCH 25/46] Grant users access to their own sync topic under deny-all Manager.Authorize had no special case for a user's own sync topic, so with auth-default-access=deny-all the per-account sync topic (st_...) fell through to the default access and was denied. This broke web app account sync for non-admin users: wss://.../st_/ws returned 403. Admin-role users were unaffected via the existing role bypass. Allow a user full access to their own SyncTopic in Authorize. This fixes existing users without a migration, since it needs no ACL row. Fixes #733 Co-Authored-By: Claude Opus 4.8 (1M context) --- user/manager.go | 7 +++++++ user/manager_test.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/user/manager.go b/user/manager.go index 34d96403..79fbe26b 100644 --- a/user/manager.go +++ b/user/manager.go @@ -639,6 +639,13 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { if user != nil && user.Role == RoleAdmin { return nil // Admin can do everything } + // A user always has full access to their own sync topic, which the apps use + // to sync subscriptions/settings across devices. Without this, an + // auth-default-access of "deny-all" locks the user out of their own sync + // topic (no ACL entry is created for it at user creation). See #733. + if user != nil && user.SyncTopic != "" && topic == user.SyncTopic { + return nil + } username := Everyone if user != nil { username = user.Name diff --git a/user/manager_test.go b/user/manager_test.go index 6d13929e..6e5a6a05 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -129,6 +129,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { require.Nil(t, a.Authorize(ben, "announcements", PermissionRead)) require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite)) + // User has full access to their own sync topic, even under deny-all, + // but not to another user's sync topic (#733) + require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionRead)) + require.Nil(t, a.Authorize(ben, ben.SyncTopic, PermissionWrite)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionRead)) + require.Equal(t, ErrUnauthorized, a.Authorize(ben, john.SyncTopic, PermissionWrite)) + // User john should have // "deny" to mytopic_deny*, // "ro" to mytopic_ro*, From 2ee8717e0c7c41381a32d68e9dfb4ae614902338 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 13:58:23 -0400 Subject: [PATCH 26/46] Don't use consts in queries --- user/manager.go | 8 ++++---- user/manager_postgres.go | 6 +++--- user/manager_sqlite.go | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/user/manager.go b/user/manager.go index 1c20367c..e98adee0 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1523,7 +1523,7 @@ func (a *Manager) UserIDByPrimaryEmail(email string) (string, error) { // PendingEmails returns the user's unverified (pending) email addresses, i.e. addresses with // an outstanding email-verification magic link. func (a *Manager) PendingEmails(userID string) ([]string, error) { - rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, userID) + rows, err := a.db.ReadOnly().Query(a.queries.selectPendingEmails, string(MagicLinkKindEmailVerify), userID) if err != nil { return nil, err } @@ -1608,11 +1608,11 @@ func (a *Manager) AddMagicLink(m *MagicLink) error { return db.ExecTx(a.db, func(tx *sql.Tx) error { switch m.Kind { case MagicLinkKindEmailVerify: - if _, err := tx.Exec(a.queries.deleteVerifyScope, m.UserID, m.Email); err != nil { + if _, err := tx.Exec(a.queries.deleteVerifyScope, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil { return err } case MagicLinkKindPasswordReset: - if _, err := tx.Exec(a.queries.deleteResetScope, m.UserID); err != nil { + if _, err := tx.Exec(a.queries.deleteResetScope, string(MagicLinkKindPasswordReset), m.UserID); err != nil { return err } default: @@ -1653,7 +1653,7 @@ func (a *Manager) DeleteMagicLink(tokenHash string) error { // DeleteEmailVerification removes any pending email verification for (userID, email). Used when // an unverified (pending) address is cancelled/deleted from the account. func (a *Manager) DeleteEmailVerification(userID, email string) error { - _, err := a.db.Exec(a.queries.deleteVerifyScope, userID, email) + _, err := a.db.Exec(a.queries.deleteVerifyScope, string(MagicLinkKindEmailVerify), userID, email) return err } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 9cedf5fd..57fcf312 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -230,9 +230,9 @@ const ( postgresInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES ($1, $2, $3, $4, $5, $6)` postgresSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = $1` postgresDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = $1` - postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 AND email = $2` - postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = $1` - postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = $1 ORDER BY email` + postgresDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2 AND email = $3` + postgresDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = $1 AND user_id = $2` + postgresSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = $1 AND user_id = $2 ORDER BY email` postgresDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < $1` // Billing queries diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 61bafc94..9bcf13df 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -227,9 +227,9 @@ const ( sqliteInsertMagicLinkQuery = `INSERT INTO user_magic_link (token_hash, kind, user_id, email, expires, created) VALUES (?, ?, ?, ?, ?, ?)` sqliteSelectMagicLinkByHashQuery = `SELECT token_hash, kind, user_id, email, expires, created FROM user_magic_link WHERE token_hash = ?` sqliteDeleteMagicLinkByHashQuery = `DELETE FROM user_magic_link WHERE token_hash = ?` - sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? AND email = ?` - sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = 'password_reset' AND user_id = ?` - sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = 'email_verify' AND user_id = ? ORDER BY email` + sqliteDeleteVerifyScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ? AND email = ?` + sqliteDeleteResetScopeQuery = `DELETE FROM user_magic_link WHERE kind = ? AND user_id = ?` + sqliteSelectPendingEmailsQuery = `SELECT email FROM user_magic_link WHERE kind = ? AND user_id = ? ORDER BY email` sqliteDeleteExpiredMagicLinksQuery = `DELETE FROM user_magic_link WHERE expires < ?` // Billing queries From bb2ca0facf0c3d55721f32702aec9d00c975b505 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 14:15:12 -0400 Subject: [PATCH 27/46] Send "X-Email: yes" to primary --- docs/config.md | 3 ++- docs/publish.md | 5 +++-- server/server_account.go | 11 +++++++++-- server/server_test.go | 39 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/config.md b/docs/config.md index bc241a4f..f51ad537 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1049,7 +1049,8 @@ configured for `ntfy.sh`): By default, any user (including anonymous users) can send email notifications to any address. To require email address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and authenticated users can only send to email addresses they have verified in their account settings. Users can -also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address. +also use `yes`/`true`/`1` as the `X-Email` value to send to their primary verified address (falling back to their +first verified address if no primary is designated). Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. diff --git a/docs/publish.md b/docs/publish.md index 0060cdcc..95354362 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3252,7 +3252,8 @@ you'd like to persist longer, or to blast-notify yourself on all possible channe Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled), -you can also pass `yes`, `true`, or `1` to send to your first verified email address. +you can also pass `yes`, `true`, or `1` to send to your **primary email address** (the one marked primary in the web app's +[Account section](https://ntfy.sh/account)). If you haven't designated a primary address, it falls back to your first verified address. ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of @@ -3702,7 +3703,7 @@ all the supported fields: | `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | -| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address | +| `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use your primary verified address | | `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | diff --git a/server/server_account.go b/server/server_account.go index c1556cf1..7598a91f 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -866,7 +866,8 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque // convertEmailAddress checks the email address against the user's verified email list. // If smtp-sender-verify is false (default), the email is passed through as-is for // backwards compatibility. If true, the user must be authenticated and the email must be -// in their verified list. "yes"/"true"/"1" resolves to the first verified email. +// in their verified list. "yes"/"true"/"1" resolves to the user's primary email (falling +// back to the first verified email if no primary is designated). func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { if !s.config.SMTPSenderVerify { if toBool(email) { @@ -885,7 +886,13 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT return "", errHTTPBadRequestEmailAddressNotVerified } if toBool(email) { - return emails[0], nil + primary, err := s.userManager.PrimaryEmail(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if primary != "" { + return primary, nil + } + return emails[0], nil // No primary designated (e.g. provisioned user); fall back to first verified } else if util.Contains(emails, email) { return email, nil } diff --git a/server/server_test.go b/server/server_test.go index eec41e03..a6106a93 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1336,14 +1336,16 @@ func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) { } type testMailer struct { - count int - mu sync.Mutex + count int + lastTo string + mu sync.Mutex } func (t *testMailer) SendNotification(to string, m *model.Message, senderIP string) error { t.mu.Lock() defer t.mu.Unlock() t.count++ + t.lastTo = to return nil } @@ -1357,6 +1359,12 @@ func (t *testMailer) Count() int { return t.count } +func (t *testMailer) LastTo() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastTo +} + func (t *testMailer) SendEmailVerification(to, link string) error { return nil } func (t *testMailer) SendPasswordReset(to, link string) error { return nil } @@ -1653,6 +1661,33 @@ func TestServer_PublishEmailVerify_BoolValue(t *testing.T) { }) } +func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.SMTPSenderVerify = true + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + // Two verified emails; the primary is NOT the alphabetically-first one + require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com")) + + // "yes" must resolve to the primary email, not emails[0] (alphabetically first) + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) + }) +} + func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) From 8a7b73cc7e5027a5c0bbd427162dd08d4fb0953c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 14:20:36 -0400 Subject: [PATCH 28/46] Remove dead strings --- web/public/static/langs/en.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 0e1ce85f..b9ec613c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -245,7 +245,6 @@ "account_basics_emails_description": "For email notifications and password reset", "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", - "account_basics_emails_primary_badge": "Primary", "account_basics_emails_chip_actions": "Click for actions", "account_basics_emails_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.", "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", @@ -253,7 +252,6 @@ "account_basics_emails_unverified": "unverified", "account_basics_emails_set_primary": "Set as primary email", "account_basics_emails_delete": "Remove address", - "account_basics_emails_cancel": "Cancel", "account_basics_emails_resend": "Resend verification email", "account_basics_emails_resent": "Verification email sent, check your inbox", "account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account", From 36d7d3bd241ccc973a1fbc9dd5761be5c3181cb1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 14:55:27 -0400 Subject: [PATCH 29/46] Rename --- web/src/components/Account.jsx | 1 + web/src/components/App.jsx | 6 +++--- web/src/components/Login.jsx | 2 +- .../{ResetPassword.jsx => PasswordResetRequest.jsx} | 8 ++++---- web/src/components/routes.js | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) rename web/src/components/{ResetPassword.jsx => PasswordResetRequest.jsx} (93%) diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 016d97e4..f37089e0 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -567,6 +567,7 @@ const AddEmailDialog = (props) => { const handleSubmit = async () => { try { setSending(true); + setError(""); // Clear any error from a previous attempt await accountApi.startEmailVerification(email); await accountApi.sync(); // Refresh so the new "(unverified)" address shows up immediately setSent(true); diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index bfa38d42..d386fedd 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -22,7 +22,7 @@ import Signup from "./Signup"; import Account from "./Account"; import EmailVerify from "./EmailVerify"; import PasswordReset from "./PasswordReset"; -import ResetPassword from "./ResetPassword"; +import PasswordResetRequest from "./PasswordResetRequest"; import initI18n from "../app/i18n"; // Translations! import prefs from "../app/Prefs"; import RTLCacheProvider from "./RTLCacheProvider"; @@ -66,9 +66,9 @@ const App = () => { } /> } /> - } /> - } /> + } /> } /> + } /> }> } /> } /> diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 9bf8e1c5..ad14ebb9 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -102,7 +102,7 @@ const Login = () => { {config.enable_reset_password && (
- + {t("login_link_forgot_password")}
diff --git a/web/src/components/ResetPassword.jsx b/web/src/components/PasswordResetRequest.jsx similarity index 93% rename from web/src/components/ResetPassword.jsx rename to web/src/components/PasswordResetRequest.jsx index 6b765d5f..3a93ba14 100644 --- a/web/src/components/ResetPassword.jsx +++ b/web/src/components/PasswordResetRequest.jsx @@ -8,11 +8,11 @@ import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import routes from "./routes"; -// ResetPassword is the standalone "request a password reset" page, reached from the login page. +// PasswordResetRequest is the standalone "request a password reset" page, reached from the login page. // It collects a username/email and asks the server to email a reset link. The response is uniform, // so the page always shows the same confirmation. Completing the reset happens on the separate // PasswordReset landing page that the emailed link points to. -const ResetPassword = () => { +const PasswordResetRequest = () => { const { t } = useTranslation(); const [identifier, setIdentifier] = useState(""); const [sending, setSending] = useState(false); @@ -24,7 +24,7 @@ const ResetPassword = () => { setSending(true); await accountApi.requestPasswordReset(identifier); } catch (e) { - console.log(`[ResetPassword] Request failed`, e); + console.log(`[PasswordResetRequest] Request failed`, e); } finally { setSending(false); setSent(true); // Uniform outcome regardless of success/failure (enumeration-safe) @@ -92,4 +92,4 @@ const ResetPassword = () => { ); }; -export default ResetPassword; +export default PasswordResetRequest; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 463fb237..e81d077b 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -4,12 +4,12 @@ import { shortUrl } from "../app/utils"; const routes = { login: "/login", signup: "/signup", - resetPassword: "/reset-password", app: config.app_root, account: "/account", settings: "/settings", - emailVerify: "/account/email/verify/:token", + passwordResetRequest: "/reset-password", passwordReset: "/account/password/reset/:token", + emailVerify: "/account/email/verify/:token", subscription: "/:topic", subscriptionExternal: "/:baseUrl/:topic", forSubscription: (subscription) => { @@ -17,7 +17,7 @@ const routes = { return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } return `/${subscription.topic}`; - }, + } }; export default routes; From 3d02c99394407e12972285ca868c4dbb8452a1c5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 15:11:54 -0400 Subject: [PATCH 30/46] Strings --- web/public/static/langs/en.json | 1 - web/src/components/PasswordReset.jsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b9ec613c..53c913f1 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -45,7 +45,6 @@ "reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.", "reset_password_success_title": "Password changed", "reset_password_success_description": "Your password has been changed. You can now sign in with your new password.", - "reset_password_button_login": "Sign in", "login_disabled": "Login is disabled", "action_bar_show_menu": "Show menu", "action_bar_logo_alt": "ntfy logo", diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx index 091a22c6..4f9b857d 100644 --- a/web/src/components/PasswordReset.jsx +++ b/web/src/components/PasswordReset.jsx @@ -56,7 +56,7 @@ const PasswordReset = () => {
{t("reset_password_success_description")} ); From 74332fa302c9b30b18ccccd344755c8f7199c4d9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 15:18:58 -0400 Subject: [PATCH 31/46] Remove unused strings --- web/public/static/langs/en.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 53c913f1..9c5793b6 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -244,7 +244,6 @@ "account_basics_emails_description": "For email notifications and password reset", "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", - "account_basics_emails_chip_actions": "Click for actions", "account_basics_emails_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.", "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", "account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.", @@ -322,7 +321,6 @@ "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", "account_upgrade_dialog_tier_features_no_calls": "No phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", - "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", "account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.", "account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.", @@ -454,7 +452,6 @@ "error_boundary_button_copy_stack_trace": "Copy stack trace", "error_boundary_button_reload_ntfy": "Reload ntfy", "error_boundary_stack_trace": "Stack trace", - "error_boundary_gathering_info": "Gather more info …", "error_boundary_unsupported_indexeddb_title": "Private browsing not supported", "error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.

While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it in this GitHub issue, or talk to us on Discord or Matrix.", "web_push_subscription_expiring_title": "Notifications will be paused", From c45744558b2d3824a247091dd0c09fe741696c3d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 20 Jun 2026 15:27:14 -0400 Subject: [PATCH 32/46] , --- web/src/components/routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/routes.js b/web/src/components/routes.js index e81d077b..fe395d1d 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -17,7 +17,7 @@ const routes = { return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } return `/${subscription.topic}`; - } + }, }; export default routes; From 914bf3b0c4091c57a66bd4d250c38bf28111d05d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 09:52:39 -0400 Subject: [PATCH 33/46] Manual refinement --- web/public/static/langs/en.json | 4 ++-- web/src/components/EmailVerify.jsx | 17 +++++++++++------ web/src/components/PasswordReset.jsx | 12 +++++++----- web/src/components/PasswordResetRequest.jsx | 3 ++- web/src/components/routes.js | 4 ++-- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 9c5793b6..c456aa3c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -30,7 +30,8 @@ "login_link_signup": "Sign up", "login_link_forgot_password": "Forgot password", "reset_password_request_title": "Reset password", - "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed. This only works if you already added a primary email address and verified it.", + "reset_password_request_description": "Enter your username or email address. If an account exists, a link to reset your password will be emailed.", + "reset_password_request_primary_required": "This only works if you already added a primary email address and verified it.", "reset_password_request_identifier_label": "Username or email", "reset_password_request_button_submit": "Send reset link", "reset_password_sent_title": "Check your inbox", @@ -41,7 +42,6 @@ "reset_password_form_password": "New password", "reset_password_form_confirm": "Confirm new password", "reset_password_form_button_submit": "Set password", - "reset_password_form_passwords_no_match": "Passwords do not match", "reset_password_form_error_invalid": "This reset link is invalid or has expired. Please request a new one.", "reset_password_success_title": "Password changed", "reset_password_success_description": "Your password has been changed. You can now sign in with your new password.", diff --git a/web/src/components/EmailVerify.jsx b/web/src/components/EmailVerify.jsx index d1f09783..8ada6886 100644 --- a/web/src/components/EmailVerify.jsx +++ b/web/src/components/EmailVerify.jsx @@ -9,6 +9,11 @@ import accountApi from "../app/AccountApi"; import AvatarBox from "./AvatarBox"; import routes from "./routes"; +// Verification states for the email-verify landing page +const STATUS_VERIFYING = "verifying"; +const STATUS_SUCCESS = "success"; +const STATUS_ERROR = "error"; + // EmailVerify is the magic-link landing page for email verification. It performs the verification // via a POST (the GET that loads this page has no side effects, so link prefetchers / scanners // cannot consume the single-use token). The raw token is stripped from the URL on load to keep @@ -17,7 +22,7 @@ const EmailVerify = () => { const { t } = useTranslation(); const { token } = useParams(); const navigate = useNavigate(); - const [status, setStatus] = useState("verifying"); // "verifying" | "success" | "error" + const [status, setStatus] = useState(STATUS_VERIFYING); const ran = useRef(false); useEffect(() => { @@ -30,23 +35,23 @@ const EmailVerify = () => { (async () => { try { await accountApi.verifyEmailToken(token); - setStatus("success"); + setStatus(STATUS_SUCCESS); } catch (e) { console.log(`[EmailVerify] Verification failed`, e); - setStatus("error"); + setStatus(STATUS_ERROR); } })(); }, [token]); return ( - {status === "verifying" && ( + {status === STATUS_VERIFYING && ( {t("email_verify_progress_title")} )} - {status === "success" && ( + {status === STATUS_SUCCESS && ( <> @@ -58,7 +63,7 @@ const EmailVerify = () => { )} - {status === "error" && ( + {status === STATUS_ERROR && ( <> diff --git a/web/src/components/PasswordReset.jsx b/web/src/components/PasswordReset.jsx index 4f9b857d..68a86eed 100644 --- a/web/src/components/PasswordReset.jsx +++ b/web/src/components/PasswordReset.jsx @@ -30,10 +30,6 @@ const PasswordReset = () => { const handleSubmit = async (event) => { event.preventDefault(); - if (password !== confirm) { - setError(t("reset_password_form_passwords_no_match")); - return; - } try { setSending(true); setError(""); @@ -91,7 +87,13 @@ const PasswordReset = () => { onChange={(ev) => setConfirm(ev.target.value.trim())} autoComplete="new-password" /> - {error && ( diff --git a/web/src/components/PasswordResetRequest.jsx b/web/src/components/PasswordResetRequest.jsx index 3a93ba14..f268dd62 100644 --- a/web/src/components/PasswordResetRequest.jsx +++ b/web/src/components/PasswordResetRequest.jsx @@ -65,7 +65,8 @@ const PasswordResetRequest = () => { {t("reset_password_request_title")} - {t("reset_password_request_description")} + {t("reset_password_request_description")} + {t("reset_password_request_primary_required")} Date: Sun, 21 Jun 2026 09:53:39 -0400 Subject: [PATCH 34/46] fmt --- web/src/components/routes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/routes.js b/web/src/components/routes.js index 68638cbc..09ffa8fd 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -17,7 +17,7 @@ const routes = { return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } return `/${subscription.topic}`; - } + }, }; export default routes; From eab3988304b747bfec0f52eaef64f91b82891512 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 10:01:07 -0400 Subject: [PATCH 35/46] Words --- web/public/static/langs/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index c456aa3c..d24e9936 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -37,7 +37,7 @@ "reset_password_sent_title": "Check your inbox", "reset_password_sent_description": "If an account exists, a link to reset your password has been emailed.", "reset_password_back_to_login": "Back to sign-in", - "reset_password_disabled": "Password reset is not enabled on this server.", + "reset_password_disabled": "Password reset is disabled", "reset_password_title": "Set a new password", "reset_password_form_password": "New password", "reset_password_form_confirm": "Confirm new password", From d7dea6d250b19389ddd3ed83a5dae223fce8558c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 11:19:49 -0400 Subject: [PATCH 36/46] Review --- cmd/user.go | 4 +- server/server_account.go | 4 +- server/server_account_email_test.go | 2 +- user/manager.go | 70 ++++++++++++++--------------- user/manager_postgres.go | 4 +- user/manager_sqlite.go | 4 +- user/manager_test.go | 48 ++++++++++++++++---- user/types.go | 39 ++++++++-------- user/util.go | 8 +--- user/util_test.go | 12 ++--- 10 files changed, 110 insertions(+), 85 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index d9d5b0fc..9e33e1da 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -350,7 +350,7 @@ func execUserResetPass(c *cli.Context) error { } } // The reset token is bound to the user, not an email -- so this works even with no SMTP - token, err := manager.CreateMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) + token, err := manager.AddMagicLink(user.MagicLinkKindPasswordReset, u.ID, "", time.Hour) if err != nil { return err } @@ -398,7 +398,7 @@ func execUserHash(c *cli.Context) error { if err != nil { return err } - hash, err := user.HashPassword(password) + hash, err := user.HashPassword(password, user.DefaultUserPasswordBcryptCost) if err != nil { return fmt.Errorf("failed to hash password: %w", err) } diff --git a/server/server_account.go b/server/server_account.go index 7598a91f..807ad453 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -783,7 +783,7 @@ func (s *Server) enqueueEmailVerification(userID, email string) error { if s.config.BaseURL == "" { return errHTTPInternalErrorMissingBaseURL } - token, err := s.userManager.CreateMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry) + token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry) if err != nil { return err } @@ -808,7 +808,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt identifier := strings.TrimSpace(req.Identifier) if identifier != "" && s.config.BaseURL != "" { if userID, email, ok := s.resolveResetTarget(identifier); ok { - token, err := s.userManager.CreateMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) + token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) if err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token") } else { diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index 9bbcccd5..88ebf1cb 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -366,7 +366,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { - hash, err := user.HashPassword("provpass") + hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) require.Nil(t, err) conf := newTestConfigWithAuthFile(t, databaseURL) conf.SMTPSenderAddr = "localhost:25" diff --git a/user/manager.go b/user/manager.go index e98adee0..8aea195a 100644 --- a/user/manager.go +++ b/user/manager.go @@ -73,6 +73,9 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { if config.AccessCacheReloadInterval <= 0 { config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval } + if config.ExpiredMagicLinkReapInterval <= 0 { + config.ExpiredMagicLinkReapInterval = DefaultExpiredMagicLinkReapInterval + } manager := &Manager{ config: config, db: d, @@ -92,7 +95,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) { go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval) } go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval) - go manager.asyncExpiredMagicLinkReapLoop(DefaultExpiredMagicLinkReapInterval) + go manager.asyncExpiredMagicLinkReapLoop(manager.config.ExpiredMagicLinkReapInterval) return manager, nil } @@ -664,7 +667,7 @@ func (a *Manager) maybeHashPassword(password string, hashed bool) (string, error } return password, nil } - return hashPassword(password, a.config.BcryptCost) + return HashPassword(password, a.config.BcryptCost) } // Authorize returns nil if the given user has access to the given topic using the desired @@ -1569,11 +1572,15 @@ func (a *Manager) SetPrimaryEmail(userID, email string) error { }) } -// CreateMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, -// replacing any existing link in the same scope), and returns the RAW token for use in the -// emailed link. Only the hash is persisted; the raw token is never stored. email is the -// address being verified for email_verify, and "" for password_reset. -func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { +// AddMagicLink generates a fresh magic-link token of the given kind, stores it (hashed, replacing +// any existing link in the same scope), and returns the RAW token for use in the emailed link. +// Only the hash is persisted; the raw token is never stored. email is the address being verified +// for email_verify, and "" for password_reset. +// +// The scope replaced is, for email_verify, the (user_id, email) pair (one pending verification per +// address); for password_reset, the user_id (one active reset per account). The replace-delete and +// the insert run in one transaction so a re-request atomically supersedes the old token. +func (a *Manager) AddMagicLink(kind MagicLinkKind, userID, email string, ttl time.Duration) (string, error) { token := generateLinkToken() now := time.Now() m := &MagicLink{ @@ -1584,35 +1591,14 @@ func (a *Manager) CreateMagicLink(kind MagicLinkKind, userID, email string, ttl Expires: now.Add(ttl).Unix(), Created: now.Unix(), } - if err := a.AddMagicLink(m); err != nil { - return "", err - } - return token, nil -} - -// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. -func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) { - return a.MagicLinkByHash(hashToken(rawToken)) -} - -// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume). -func (a *Manager) DeleteMagicLinkByToken(rawToken string) error { - return a.DeleteMagicLink(hashToken(rawToken)) -} - -// AddMagicLink stores a pending magic link, replacing any existing link in the same scope: -// for email_verify that is the (user_id, email) pair (one pending verification per address); -// for password_reset that is the user_id (one active reset per account). The replace-delete and -// the insert run in one transaction so a re-request atomically supersedes the old token. -func (a *Manager) AddMagicLink(m *MagicLink) error { - return db.ExecTx(a.db, func(tx *sql.Tx) error { + err := db.ExecTx(a.db, func(tx *sql.Tx) error { switch m.Kind { case MagicLinkKindEmailVerify: - if _, err := tx.Exec(a.queries.deleteVerifyScope, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil { + if _, err := tx.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), m.UserID, m.Email); err != nil { return err } case MagicLinkKindPasswordReset: - if _, err := tx.Exec(a.queries.deleteResetScope, string(MagicLinkKindPasswordReset), m.UserID); err != nil { + if _, err := tx.Exec(a.queries.deleteMagicLinkResetPassword, string(MagicLinkKindPasswordReset), m.UserID); err != nil { return err } default: @@ -1623,6 +1609,15 @@ func (a *Manager) AddMagicLink(m *MagicLink) error { } return nil }) + if err != nil { + return "", err + } + return token, nil +} + +// MagicLinkByToken looks up a magic link by its raw token (hashing it first). See MagicLinkByHash. +func (a *Manager) MagicLinkByToken(rawToken string) (*MagicLink, error) { + return a.MagicLinkByHash(hashToken(rawToken)) } // MagicLinkByHash looks up a magic link by the hex SHA-256 of its raw token, returning @@ -1643,17 +1638,18 @@ func (a *Manager) MagicLinkByHash(tokenHash string) (*MagicLink, error) { return &m, nil } -// DeleteMagicLink deletes a magic link by its token hash. Used to enforce single use after a -// reset is performed (email verification deletes the row inside VerifyEmail's transaction). -func (a *Manager) DeleteMagicLink(tokenHash string) error { - _, err := a.db.Exec(a.queries.deleteMagicLinkByHash, tokenHash) +// DeleteMagicLinkByToken deletes a magic link identified by its raw token (single-use consume). +// Used to enforce single use after a reset is performed (email verification deletes the row +// inside VerifyEmail's transaction). +func (a *Manager) DeleteMagicLinkByToken(rawToken string) error { + _, err := a.db.Exec(a.queries.deleteMagicLinkByHash, hashToken(rawToken)) return err } // DeleteEmailVerification removes any pending email verification for (userID, email). Used when // an unverified (pending) address is cancelled/deleted from the account. func (a *Manager) DeleteEmailVerification(userID, email string) error { - _, err := a.db.Exec(a.queries.deleteVerifyScope, string(MagicLinkKindEmailVerify), userID, email) + _, err := a.db.Exec(a.queries.deleteMagicLinkEmailVerify, string(MagicLinkKindEmailVerify), userID, email) return err } @@ -1736,7 +1732,7 @@ func (a *Manager) ResetPassword(rawToken, password string) error { if u.Provisioned { return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset } - hash, err := a.maybeHashPassword(password, false) + hash, err := HashPassword(password, a.config.BcryptCost) if err != nil { return err } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 57fcf312..a9e12d50 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -329,8 +329,8 @@ var postgresQueries = queries{ insertMagicLink: postgresInsertMagicLinkQuery, selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, - deleteVerifyScope: postgresDeleteVerifyScopeQuery, - deleteResetScope: postgresDeleteResetScopeQuery, + deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, selectPendingEmails: postgresSelectPendingEmailsQuery, deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, updateBilling: postgresUpdateBillingQuery, diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 9bcf13df..a2a46e18 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -325,8 +325,8 @@ var sqliteQueries = queries{ insertMagicLink: sqliteInsertMagicLinkQuery, selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, - deleteVerifyScope: sqliteDeleteVerifyScopeQuery, - deleteResetScope: sqliteDeleteResetScopeQuery, + deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, selectPendingEmails: sqliteSelectPendingEmailsQuery, deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, updateBilling: sqliteUpdateBillingQuery, diff --git a/user/manager_test.go b/user/manager_test.go index 6bc0bccd..c6dd2467 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -2,6 +2,7 @@ package user import ( "database/sql" + "errors" "fmt" "net/netip" "path/filepath" @@ -1847,7 +1848,7 @@ func TestMigrationFrom7(t *testing.T) { require.Equal(t, "", primary) // The new magic-link machinery works post-migration - raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, "u_phil", "new@example.com", 24*time.Hour) + raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, "u_phil", "new@example.com", 24*time.Hour) require.Nil(t, err) m, err := a.VerifyEmail(raw) require.Nil(t, err) @@ -2901,7 +2902,7 @@ func TestStoreOtherAccessCount(t *testing.T) { // addVerifyLink stores an email-verification magic link and returns the raw token so the test // can "click" it via VerifyEmail. func addVerifyLink(t *testing.T, a *Manager, userID, email string, ttl time.Duration) string { - raw, err := a.CreateMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) + raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, userID, email, ttl) require.Nil(t, err) return raw } @@ -3083,7 +3084,7 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) require.Nil(t, err) m, err := a.MagicLinkByToken(raw) @@ -3098,7 +3099,7 @@ func TestUser_MagicLink_PasswordReset_RoundTrip(t *testing.T) { require.Equal(t, 0, len(pending)) // New request replaces the old token - raw2, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + raw2, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) require.Nil(t, err) _, err = a.MagicLinkByToken(raw) require.ErrorIs(t, err, ErrMagicLinkNotFound) @@ -3130,6 +3131,37 @@ func TestUser_MagicLink_Reaper(t *testing.T) { }) } +// TestUser_MagicLink_ReaperLoop proves the background reap goroutine actually runs on its +// configured interval: an expired link inserted into a manager with a tiny reap interval is +// deleted without anyone calling deleteExpiredMagicLinks directly. Mirrors the loop-coverage +// pattern of TestAccessCacheReloadInterval_PicksUpExternalWrite. +func TestUser_MagicLink_ReaperLoop(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManagerFromConfig(t, newManager, &Config{ + DefaultAccess: PermissionDenyAll, + BcryptCost: bcrypt.MinCost, + ExpiredMagicLinkReapInterval: 25 * time.Millisecond, + }) + require.Nil(t, a.AddUser("phil", "phil", RoleUser, false)) + phil, err := a.User("phil") + require.Nil(t, err) + + expired := addVerifyLink(t, a, phil.ID, "expired@example.com", -time.Hour) + valid := addVerifyLink(t, a, phil.ID, "valid@example.com", time.Hour) + + // The background loop (not a direct call) must reap the expired link within a few intervals + require.Eventually(t, func() bool { + _, err := a.MagicLinkByToken(expired) + return errors.Is(err, ErrMagicLinkNotFound) + }, 2*time.Second, 10*time.Millisecond, "reaper loop never deleted the expired magic link") + + // The unexpired link must survive + m, err := a.MagicLinkByToken(valid) + require.Nil(t, err) + require.Equal(t, "valid@example.com", m.Email) + }) +} + func TestUser_MagicLink_ResetPassword(t *testing.T) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { a := newTestManager(t, newManager, PermissionDenyAll) @@ -3137,7 +3169,7 @@ func TestUser_MagicLink_ResetPassword(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) require.Nil(t, err) // Old password works before reset @@ -3169,7 +3201,7 @@ func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { require.ErrorIs(t, a.ResetPassword(verifyToken, "newpass"), ErrMagicLinkNotFound) // ...and a reset token must not be usable for email verification - resetToken, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) + resetToken, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", time.Hour) require.Nil(t, err) _, err = a.VerifyEmail(resetToken) require.ErrorIs(t, err, ErrMagicLinkNotFound) @@ -3221,7 +3253,7 @@ func TestUser_MagicLink_ResetPassword_ProvisionedRejected(t *testing.T) { // A reset token can be created, but consuming it must be rejected for a provisioned user // (their password comes from the config file, like change-pass). - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, prov.ID, "", time.Hour) require.Nil(t, err) require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrProvisionedUserChange) }) @@ -3234,7 +3266,7 @@ func TestUser_MagicLink_ResetPassword_Expired(t *testing.T) { phil, err := a.User("phil") require.Nil(t, err) - raw, err := a.CreateMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) + raw, err := a.AddMagicLink(MagicLinkKindPasswordReset, phil.ID, "", -time.Minute) require.Nil(t, err) require.ErrorIs(t, a.ResetPassword(raw, "newpass"), ErrMagicLinkNotFound) _, err = a.Authenticate("phil", "oldpass") diff --git a/user/types.go b/user/types.go index ce7efe36..e198b273 100644 --- a/user/types.go +++ b/user/types.go @@ -266,18 +266,19 @@ const ( // Config holds the configuration for the user Manager type Config struct { - Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) - DatabaseURL string // Database connection string (PostgreSQL) - StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) - DefaultAccess Permission // Default permission if no ACL matches - ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands - Users []*User // Predefined users to create on startup - Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) - Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) - 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 - AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) - AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI + Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite) + DatabaseURL string // Database connection string (PostgreSQL) + StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only) + DefaultAccess Permission // Default permission if no ACL matches + ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands + Users []*User // Predefined users to create on startup + Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant) + Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token) + 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 + AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) + AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI + ExpiredMagicLinkReapInterval time.Duration // Interval for sweeping expired email-verify/password-reset links } // Error constants used by the package @@ -383,13 +384,13 @@ type queries struct { updateEmailClearPrimary string // Magic link queries (email verification + password reset) - insertMagicLink string - selectMagicLinkByHash string - deleteMagicLinkByHash string - deleteVerifyScope string // Delete pending email_verify rows for (user_id, email) - deleteResetScope string // Delete the active password_reset row for user_id - selectPendingEmails string // Pending (unverified) email addresses for a user - deleteExpiredMagicLinks string + insertMagicLink string + selectMagicLinkByHash string + deleteMagicLinkByHash string + deleteMagicLinkEmailVerify string // Delete pending email_verify rows for (user_id, email) + deleteMagicLinkResetPassword string // Delete the active password_reset row for user_id + selectPendingEmails string // Pending (unverified) email addresses for a user + deleteExpiredMagicLinks string // Billing queries updateBilling string diff --git a/user/util.go b/user/util.go index 5157b95f..50d0c012 100644 --- a/user/util.go +++ b/user/util.go @@ -89,12 +89,8 @@ func hashToken(raw string) string { return hex.EncodeToString(sum[:]) } -// HashPassword hashes the given password using bcrypt with the configured cost -func HashPassword(password string) (string, error) { - return hashPassword(password, DefaultUserPasswordBcryptCost) -} - -func hashPassword(password string, cost int) (string, error) { +// HashPassword hashes the given password using bcrypt with the given cost +func HashPassword(password string, cost int) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) if err != nil { return "", err diff --git a/user/util_test.go b/user/util_test.go index 97c4bc4a..2fa542ab 100644 --- a/user/util_test.go +++ b/user/util_test.go @@ -176,7 +176,7 @@ func TestHashPassword(t *testing.T) { password := "test-password-123" // Hash the password - hash, err := HashPassword(password) + hash, err := HashPassword(password, DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEmpty(t, hash) @@ -187,12 +187,12 @@ func TestHashPassword(t *testing.T) { require.True(t, strings.HasPrefix(hash, "$2a$")) // Hash the same password again - should produce different hash - hash2, err := HashPassword(password) + hash2, err := HashPassword(password, DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)") // Empty password should still work - emptyHash, err := HashPassword("") + emptyHash, err := HashPassword("", DefaultUserPasswordBcryptCost) require.Nil(t, err) require.NotEmpty(t, emptyHash) require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost)) @@ -202,15 +202,15 @@ func TestHashPassword_WithCost(t *testing.T) { password := "test-password" // Test with different costs - hash4, err := hashPassword(password, 4) + hash4, err := HashPassword(password, 4) require.Nil(t, err) require.True(t, strings.HasPrefix(hash4, "$2a$04$")) - hash10, err := hashPassword(password, 10) + hash10, err := HashPassword(password, 10) require.Nil(t, err) require.True(t, strings.HasPrefix(hash10, "$2a$10$")) - hash12, err := hashPassword(password, 12) + hash12, err := HashPassword(password, 12) require.Nil(t, err) require.True(t, strings.HasPrefix(hash12, "$2a$12$")) From 1979dbc7c3e058a8d8f13fd79337fbb1d2adc669 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 11:27:02 -0400 Subject: [PATCH 37/46] Rename --- server/server_account.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/server_account.go b/server/server_account.go index 807ad453..5951d72f 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -807,7 +807,7 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing identifier := strings.TrimSpace(req.Identifier) if identifier != "" && s.config.BaseURL != "" { - if userID, email, ok := s.resolveResetTarget(identifier); ok { + if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok { token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry) if err != nil { logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token") @@ -825,11 +825,11 @@ func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *htt return s.writeJSON(w, newSuccessResponse()) } -// resolveResetTarget resolves a reset identifier (username or primary email) to a single account +// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account // and its primary email. It applies the reset policy on top of the lookup: provisioned users are // excluded, and ok=false is returned unless the account has a verified primary email (reset // requires one, and that is where the link is sent). -func (s *Server) resolveResetTarget(identifier string) (userID string, email string, ok bool) { +func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) { u, err := s.userManager.UserByEmailOrUsername(identifier) if err != nil || u == nil || u.Provisioned { return "", "", false From 954fae44dc865b2ab8b7201afd0261759932e6fb Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 11:47:14 -0400 Subject: [PATCH 38/46] Make more readable --- user/manager.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/user/manager.go b/user/manager.go index 8aea195a..4a4aa75f 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1681,6 +1681,7 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { return err } + // Must stay before the promotion block; covered by TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary if u.Provisioned { return nil // Provisioned users don't get a primary (recovery) email } @@ -1695,16 +1696,17 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if primary.String != "" { return nil // User already has a primary -- leave it } - var owner string - err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&owner) - if errors.Is(err, sql.ErrNoRows) { - if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil { - return err - } - } else if err != nil { + // If the address is already another account's primary, leave it a verified secondary here + var ownerUserID string + if err = tx.QueryRow(a.queries.selectUserIDByPrimary, m.Email).Scan(&ownerUserID); err == nil { + return nil // Address is primary elsewhere -> not promoted + } else if !errors.Is(err, sql.ErrNoRows) { + return err // Real query error + } + // Address is globally free -> promote it to this user's primary + if _, err := tx.Exec(a.queries.updateEmailSetPrimary, m.UserID, m.Email); err != nil { return err } - // owner found -> address is primary elsewhere -> stays a verified secondary return nil }) if err != nil { From 5808c4d4c0948b35f145ba103b5f9cd185e39b28 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 Jun 2026 11:53:25 -0400 Subject: [PATCH 39/46] Rename variable --- user/manager.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user/manager.go b/user/manager.go index 4a4aa75f..6378ab60 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1719,7 +1719,7 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { // validating the token (kind + expiry), it sets the user's password and deletes the link in one // transaction. Existing access tokens are intentionally left valid (only the password changes). // Returns ErrMagicLinkNotFound if the token is invalid, expired, or not a reset token. -func (a *Manager) ResetPassword(rawToken, password string) error { +func (a *Manager) ResetPassword(rawToken, newPassword string) error { m, err := a.MagicLinkByHash(hashToken(rawToken)) if err != nil { return err @@ -1734,7 +1734,7 @@ func (a *Manager) ResetPassword(rawToken, password string) error { if u.Provisioned { return ErrProvisionedUserChange // Provisioned users get their password from the config file, not reset } - hash, err := HashPassword(password, a.config.BcryptCost) + hash, err := HashPassword(newPassword, a.config.BcryptCost) if err != nil { return err } From d8666b66ecd65a1a5cd3c0219506276819b9b482 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 10:13:27 -0400 Subject: [PATCH 40/46] Change "Email: yes" behavior to make more sense --- docs/config.md | 10 ++++--- docs/publish.md | 10 +++++-- docs/releases.md | 3 +- server/server.go | 1 - server/server_account.go | 50 +++++++++++++++++++++------------- server/server_test.go | 32 ++++++++++++++++++++-- server/types.go | 1 - web/src/components/Account.jsx | 2 +- 8 files changed, 76 insertions(+), 33 deletions(-) diff --git a/docs/config.md b/docs/config.md index f51ad537..0f72557b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1047,10 +1047,12 @@ configured for `ntfy.sh`): ``` By default, any user (including anonymous users) can send email notifications to any address. To require email -address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, -and authenticated users can only send to email addresses they have verified in their account settings. Users can -also use `yes`/`true`/`1` as the `X-Email` value to send to their primary verified address (falling back to their -first verified address if no primary is designated). +address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails, and +authenticated users can only send to *literal* email addresses they have verified in their account settings. + +Regardless of this setting, a logged-in user can pass `yes`/`true`/`1` as the `X-Email` value to send to their primary +verified address (falling back to their first verified address if no primary is designated). `smtp-sender-verify` only +governs whether arbitrary literal addresses are allowed. Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. diff --git a/docs/publish.md b/docs/publish.md index 95354362..46f2d80a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3251,9 +3251,13 @@ You can forward messages to e-mail by specifying an address in the header. This you'd like to persist longer, or to blast-notify yourself on all possible channels. Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). -Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled), -you can also pass `yes`, `true`, or `1` to send to your **primary email address** (the one marked primary in the web app's -[Account section](https://ntfy.sh/account)). If you haven't designated a primary address, it falls back to your first verified address. +Only one e-mail address is supported. + +If you are logged in and have a verified email address on your account, you can pass `yes`, `true`, or `1` instead of an +address to send to your **primary email address** (the one marked primary in the web app's +[Account section](https://ntfy.sh/account)); if you haven't designated a primary, it falls back to your first verified +address. This works regardless of the [`smtp-sender-verify`](config.md#e-mail-notifications) setting -- that setting only +controls whether *literal* addresses must already be verified on your account. ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of diff --git a/docs/releases.md b/docs/releases.md index d2de3d07..d0551d6b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1957,13 +1957,14 @@ email. All of this rides on the existing SMTP configuration -- no new config fla **Features:** -* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user password-reset` CLI command for admins +* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user reset-pass` CLI command for admins * Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI * Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery **Bug fixes + maintenance:** * Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG +* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address ### ntfy Android v1.25.x (UNRELEASED) diff --git a/server/server.go b/server/server.go index d79d7ad0..fb5d5d37 100644 --- a/server/server.go +++ b/server/server.go @@ -746,7 +746,6 @@ func (s *Server) configResponse() *apiConfigResponse { EnablePayments: s.config.StripeSecretKey != "", EnableCalls: s.config.TwilioAccount != "", EnableEmails: s.config.SMTPSenderFrom != "", - EnableEmailVerify: s.config.SMTPSenderVerify, EnableResetPassword: s.config.SMTPSenderFrom != "" && s.config.BaseURL != "", // Reset links need SMTP + an absolute base-url EnableReservations: s.config.EnableReservations, EnableWebPush: s.config.WebPushPublicKey != "", diff --git a/server/server_account.go b/server/server_account.go index 5951d72f..ac79b05a 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -863,16 +863,39 @@ func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Reque return s.writeJSON(w, newSuccessResponse()) } -// convertEmailAddress checks the email address against the user's verified email list. -// If smtp-sender-verify is false (default), the email is passed through as-is for -// backwards compatibility. If true, the user must be authenticated and the email must be -// in their verified list. "yes"/"true"/"1" resolves to the user's primary email (falling -// back to the first verified email if no primary is designated). +// convertEmailAddress resolves the X-Email value to the address ntfy should send to. +// +// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is +// designated (e.g. a provisioned user), the first verified address (alphabetically). This is +// independent of smtp-sender-verify: it only requires an authenticated user with a verified +// address, since it means "send to my own email". +// +// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards +// compatible); when true, the address must be one the user has verified. func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) { - if !s.config.SMTPSenderVerify { - if toBool(email) { - return "", errHTTPBadRequestEmailAddressInvalid + if toBool(email) { + if u == nil { + return "", errHTTPBadRequestAnonymousEmailNotAllowed + } else if s.userManager == nil { + return "", errHTTPBadRequestEmailAddressNotVerified } + primary, err := s.userManager.PrimaryEmail(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if primary != "" { + return primary, nil + } + // No primary designated -> fall back to the first verified address, if any + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if len(emails) > 0 { + return emails[0], nil + } + return "", errHTTPBadRequestEmailAddressNotVerified + } + // A literal address + if !s.config.SMTPSenderVerify { return email, nil } else if u == nil { return "", errHTTPBadRequestAnonymousEmailNotAllowed @@ -882,17 +905,6 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT emails, err := s.userManager.Emails(u.ID) if err != nil { return "", errHTTPInternalError - } else if len(emails) == 0 { - return "", errHTTPBadRequestEmailAddressNotVerified - } - if toBool(email) { - primary, err := s.userManager.PrimaryEmail(u.ID) - if err != nil { - return "", errHTTPInternalError - } else if primary != "" { - return primary, nil - } - return emails[0], nil // No primary designated (e.g. provisioned user); fall back to first verified } else if util.Contains(emails, email) { return email, nil } diff --git a/server/server_test.go b/server/server_test.go index a6106a93..eaa7f350 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1688,17 +1688,43 @@ func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) { }) } -func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) { +func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + conf := newTestConfigWithAuthFile(t, databaseURL) + // smtp-sender-verify intentionally left false (the default) + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(u.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(u.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "zzz@example.com")) + + // Even with smtp-sender-verify off, "yes" resolves to the user's primary verified address + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) + }) +} + +func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfig(t, databaseURL)) s.mailer = &testMailer{} - // "yes" without smtp-sender-verify should fail with invalid address + // "yes" requires an authenticated user (it means "my primary"); anonymous is rejected response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ "Email": "yes", }) require.Equal(t, 400, response.Code) - require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code) }) } diff --git a/server/types.go b/server/types.go index 4b0c738d..5b254c4d 100644 --- a/server/types.go +++ b/server/types.go @@ -338,7 +338,6 @@ type apiConfigResponse struct { EnablePayments bool `json:"enable_payments"` EnableCalls bool `json:"enable_calls"` EnableEmails bool `json:"enable_emails"` - EnableEmailVerify bool `json:"enable_email_verify"` EnableResetPassword bool `json:"enable_reset_password"` EnableReservations bool `json:"enable_reservations"` EnableWebPush bool `json:"enable_web_push"` diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index f37089e0..f225cc81 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -428,7 +428,7 @@ const Emails = () => { setSnack(t("account_basics_emails_resent")); }); - if (!config.enable_email_verify) { + if (!config.enable_emails) { return null; } From 4313b02fc6f62aca622b1504241e7f536c4d1775 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 12:59:50 -0400 Subject: [PATCH 41/46] Allow primary email for provisioend users --- docs/releases.md | 2 +- server/server_account.go | 2 -- server/server_account_email_test.go | 17 ++++++++++------- server/server_test.go | 27 +++++++++++++++++++++++++++ user/manager.go | 8 -------- user/manager_test.go | 7 ++++--- web/public/static/langs/en.json | 3 +-- web/src/components/Account.jsx | 8 +------- 8 files changed, 44 insertions(+), 30 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index d0551d6b..30490217 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1958,7 +1958,7 @@ email. All of this rides on the existing SMTP configuration -- no new config fla **Features:** * Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user reset-pass` CLI command for admins -* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" (recovery) email with verified/unverified state in the account UI +* Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI * Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery **Bug fixes + maintenance:** diff --git a/server/server_account.go b/server/server_account.go index ac79b05a..e28df08d 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -735,8 +735,6 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req return err } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } else if u.Provisioned { - return errHTTPConflictProvisionedUserChange // Provisioned users can't reset, so a recovery email is meaningless } logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email") err = s.userManager.SetPrimaryEmail(u.ID, req.Email) diff --git a/server/server_account_email_test.go b/server/server_account_email_test.go index 88ebf1cb..793c89be 100644 --- a/server/server_account_email_test.go +++ b/server/server_account_email_test.go @@ -364,7 +364,7 @@ func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) { }) } -func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { +func TestAccount_Email_ProvisionedPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) require.Nil(t, err) @@ -379,16 +379,19 @@ func TestAccount_Email_ProvisionedNoPrimary(t *testing.T) { defer s.closeDatabases() auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")} - // A provisioned user can verify an email, but it must NOT become their primary + // A provisioned user's first verified email becomes their primary (used by X-Email: yes; + // password reset stays blocked separately for provisioned users) verifyEmailFor(t, s, mailer, auth, "prov@example.com") account := getAccount(t, s, auth) require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account)) - require.Equal(t, "", primaryAddr(account)) + require.Equal(t, "prov@example.com", primaryAddr(account)) - // Explicitly setting it primary is rejected - rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov@example.com"}`, auth) - require.Equal(t, 409, rr.Code) - require.Equal(t, 40905, toHTTPError(t, rr.Body.String()).Code) + // Verify a second address and explicitly set it primary -> allowed, star moves + verifyEmailFor(t, s, mailer, auth, "prov2@example.com") + rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth) + require.Equal(t, 200, rr.Code) + account = getAccount(t, s, auth) + require.Equal(t, "prov2@example.com", primaryAddr(account)) }) } diff --git a/server/server_test.go b/server/server_test.go index eaa7f350..5ebde045 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1728,6 +1728,33 @@ func TestServer_PublishEmailVerify_BoolValueAnonymousRejected(t *testing.T) { }) } +func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost) + require.Nil(t, err) + conf := newTestConfigWithAuthFile(t, databaseURL) + conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}} + s := newTestServer(t, conf) + mailer := &testMailer{} + s.mailer = mailer + defer s.closeDatabases() + + prov, err := s.userManager.User("prov") + require.Nil(t, err) + require.Nil(t, s.userManager.AddEmail(prov.ID, "aaa@example.com")) + require.Nil(t, s.userManager.AddEmail(prov.ID, "zzz@example.com")) + require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "zzz@example.com")) + + // A provisioned user's "yes" resolves to their chosen primary, not the alphabetically-first + response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{ + "Email": "yes", + "Authorization": util.BasicAuth("prov", "provpass"), + }) + require.Equal(t, 200, response.Code) + require.Equal(t, "zzz@example.com", mailer.LastTo()) + }) +} + func TestServer_PublishEmailVerify_Anonymous(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { conf := newTestConfigWithAuthFile(t, databaseURL) diff --git a/user/manager.go b/user/manager.go index 6378ab60..c8b1d39e 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1669,10 +1669,6 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if m.Kind != MagicLinkKindEmailVerify || time.Now().Unix() > m.Expires { return nil, ErrMagicLinkNotFound } - u, err := a.UserByID(m.UserID) - if err != nil { - return nil, err - } err = db.ExecTx(a.db, func(tx *sql.Tx) error { // Single use: delete the link, then add the (idempotent) verified address if _, err := tx.Exec(a.queries.deleteMagicLinkByHash, tokenHash); err != nil { @@ -1681,10 +1677,6 @@ func (a *Manager) VerifyEmail(rawToken string) (*MagicLink, error) { if _, err := tx.Exec(a.queries.insertEmailIgnore, m.UserID, m.Email); err != nil { return err } - // Must stay before the promotion block; covered by TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary - if u.Provisioned { - return nil // Provisioned users don't get a primary (recovery) email - } // Promote to primary only if the user has none yet and the address is globally free. // We check with SELECTs rather than catching a unique violation, because Postgres aborts // the whole transaction on any constraint error (which would undo the verified-email add). diff --git a/user/manager_test.go b/user/manager_test.go index c6dd2467..e7073773 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -3212,7 +3212,7 @@ func TestUser_MagicLink_ResetPassword_WrongKindRejected(t *testing.T) { }) } -func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { +func TestUser_MagicLink_VerifyEmail_ProvisionedGetsPrimary(t *testing.T) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { a := newTestManagerFromConfig(t, newManager, &Config{ DefaultAccess: PermissionDenyAll, @@ -3224,7 +3224,8 @@ func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { prov, err := a.User("prov") require.Nil(t, err) - // A provisioned user can verify an email (for notifications), but it must NOT become primary + // A provisioned user's first verified email becomes their primary, just like a regular user + // (the primary is also the X-Email: yes target; password reset stays blocked separately). _, err = a.VerifyEmail(addVerifyLink(t, a, prov.ID, "prov@example.com", time.Hour)) require.Nil(t, err) @@ -3233,7 +3234,7 @@ func TestUser_MagicLink_VerifyEmail_ProvisionedNoPrimary(t *testing.T) { require.Equal(t, []string{"prov@example.com"}, emails) primary, err := a.PrimaryEmail(prov.ID) require.Nil(t, err) - require.Equal(t, "", primary) + require.Equal(t, "prov@example.com", primary) }) } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index d24e9936..a6e942a0 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -244,7 +244,7 @@ "account_basics_emails_description": "For email notifications and password reset", "account_basics_emails_no_emails_yet": "No emails yet", "account_basics_emails_copied_to_clipboard": "Email address copied to clipboard", - "account_basics_emails_chip_actions_primary": "Primary address, can be used for account recovery and notifications. Click for actions.", + "account_basics_emails_chip_actions_primary": "Primary address, used as your default email address. Click for actions.", "account_basics_emails_chip_actions_verified": "Can be used for notifications. Click for actions.", "account_basics_emails_chip_actions_unverified": "Unverified address, check your inbox to verify. Click for actions.", "account_basics_emails_unverified": "unverified", @@ -255,7 +255,6 @@ "account_basics_emails_primary_elsewhere": "This email address is used as the primary address on another account", "account_basics_emails_no_recovery_warning": "Add at least one email address to ensure you can recover your account if you lose your password.", "account_basics_emails_no_primary_warning": "Add a primary email address to ensure you can recover your account if you lose your password.", - "account_basics_emails_provisioned_info": "Provisioned users cannot add a primary email address, but you can still add an email address for notifications.", "account_basics_emails_dialog_title": "Add email address", "account_basics_emails_dialog_description": "Enter an email address to add it to your account. A verification link will be sent to confirm it is yours.", "account_basics_emails_dialog_email_label": "Email address", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index f225cc81..24631dc9 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -521,7 +521,7 @@ const Emails = () => { {t("common_copy_to_clipboard")} - {menuEmail && !menuEmail.pending && !menuEmail.primary && !account?.provisioned && ( + {menuEmail && !menuEmail.pending && !menuEmail.primary && ( runMenuAction(handleSetPrimary)}> @@ -555,7 +555,6 @@ const Emails = () => { const AddEmailDialog = (props) => { const theme = useTheme(); const { t } = useTranslation(); - const { account } = useContext(AccountContext); const [error, setError] = useState(""); const [email, setEmail] = useState(""); const [sending, setSending] = useState(false); @@ -592,11 +591,6 @@ const AddEmailDialog = (props) => { ) : ( <> {t("account_basics_emails_dialog_description")} - {config.enable_reset_password && account?.provisioned && ( - - {t("account_basics_emails_provisioned_info")} - - )} Date: Mon, 22 Jun 2026 17:02:47 -0400 Subject: [PATCH 42/46] Remove migration test --- user/manager_test.go | 70 -------------------------------------------- 1 file changed, 70 deletions(-) diff --git a/user/manager_test.go b/user/manager_test.go index e7073773..88d7d122 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -1790,76 +1790,6 @@ func TestMigrationFrom4(t *testing.T) { require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters } -func TestMigrationFrom7(t *testing.T) { - filename := filepath.Join(t.TempDir(), "user.db") - rawDB, err := sql.Open("sqlite3", filename) - require.Nil(t, err) - - // Create a "version 7" schema: user_email exists but has no is_primary column, and there - // is no user_magic_link table yet. (Mirrors the production schema right before v8.) - _, err = rawDB.Exec(` - BEGIN; - CREATE TABLE IF NOT EXISTS tier ( - id TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL, - messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, - calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, - attachment_total_size_limit INT NOT NULL, attachment_expiry_duration INT NOT NULL, - attachment_bandwidth_limit INT NOT NULL, stripe_monthly_price_id TEXT, stripe_yearly_price_id TEXT - ); - 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) - ); - CREATE UNIQUE INDEX idx_user ON user (user); - CREATE TABLE IF NOT EXISTS user_email ( - user_id TEXT NOT NULL, - email TEXT NOT NULL, - PRIMARY KEY (user_id, email), - FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); - CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL); - INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) - VALUES ('u_everyone', '*', '', 'anonymous', '', 0, UNIXEPOCH()); - INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) - VALUES ('u_phil', 'phil', '', 'user', 'st_phil', 0, UNIXEPOCH()); - INSERT INTO user_email (user_id, email) VALUES ('u_phil', 'old@example.com'); - INSERT INTO schemaVersion (id, version) VALUES (1, 7); - COMMIT; - `) - require.Nil(t, err) - require.Nil(t, rawDB.Close()) - - // Opening the manager triggers the 7 -> 8 migration - a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval) - checkSchemaVersion(t, testDB(a)) - - // The pre-existing verified email survives and stays NON-primary (no backfill) - emails, err := a.Emails("u_phil") - require.Nil(t, err) - require.Equal(t, []string{"old@example.com"}, emails) - primary, err := a.PrimaryEmail("u_phil") - require.Nil(t, err) - require.Equal(t, "", primary) - - // The new magic-link machinery works post-migration - raw, err := a.AddMagicLink(MagicLinkKindEmailVerify, "u_phil", "new@example.com", 24*time.Hour) - require.Nil(t, err) - m, err := a.VerifyEmail(raw) - require.Nil(t, err) - require.Equal(t, "new@example.com", m.Email) - - // new@ becomes primary because the user had none (old@ was a pre-existing non-primary) - primary, err = a.PrimaryEmail("u_phil") - require.Nil(t, err) - require.Equal(t, "new@example.com", primary) -} - func checkSchemaVersion(t *testing.T, d *db.DB) { rows, err := d.Query(`SELECT version FROM schemaVersion`) require.Nil(t, err) From 2b30ce9ee022f8dfde9736570f09a160f74e64c9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 21:07:55 -0400 Subject: [PATCH 43/46] Changelog update --- docs/releases.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 30490217..a3c7db5c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1953,13 +1953,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release This release adds **password reset** via email, and reworks email verification to use durable, link-based magic links (replacing the old in-memory 6-digit codes). Email stays optional at signup; a user can reset their password only once they have a verified "primary" (recovery) -email. All of this rides on the existing SMTP configuration -- no new config flag. +email. + +All of this work is probably not useful for self-hosters, but it hopefully will be useful for me, +since I do have to reset emails on a regular basis. **Features:** -* Add password reset via emailed magic link, with a "Forgot password?" link on the login page and a `ntfy user reset-pass` CLI command for admins +* Add password reset via emailed magic link, with a "Forgot password" link on the login page and a `ntfy user reset-pass` CLI command for admins * Rework email verification to use durable, single-use, expiring magic links instead of in-memory 6-digit codes, and add a "primary" email (used for account recovery and as the `X-Email: yes` target) with verified/unverified state in the account UI -* Auto-send a verification link to the billing email after a Stripe checkout, so paying users can set up password recovery **Bug fixes + maintenance:** From 7b37f2a3ebb05ed13f23ef494f0cd4205fe48542 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 21:10:24 -0400 Subject: [PATCH 44/46] Bump --- go.mod | 36 ++-- go.sum | 76 +++---- web/package-lock.json | 483 ++++++++++++++++++++++++------------------ 3 files changed, 332 insertions(+), 263 deletions(-) diff --git a/go.mod b/go.mod index 8d71ca73..97ee718c 100644 --- a/go.mod +++ b/go.mod @@ -4,22 +4,22 @@ go 1.25.8 require ( cloud.google.com/go/firestore v1.22.0 // indirect - cloud.google.com/go/storage v1.62.2 // indirect + cloud.google.com/go/storage v1.62.3 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/emersion/go-smtp v0.24.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.47 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 - golang.org/x/term v0.43.0 + golang.org/x/sync v0.21.0 + golang.org/x/term v0.44.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.283.0 + google.golang.org/api v0.286.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -34,8 +34,8 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.45.0 - golang.org/x/text v0.37.0 + golang.org/x/sys v0.46.0 + golang.org/x/text v0.38.0 ) require ( @@ -48,9 +48,9 @@ require ( cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -79,10 +79,10 @@ 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.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -94,11 +94,11 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/net v0.56.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 8ba2d94d..baa653f9 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= -cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= -cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0= +cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= @@ -28,14 +28,14 @@ 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.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= 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= @@ -66,8 +66,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -139,16 +139,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 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.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= -github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= +github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= +github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -195,8 +195,8 @@ 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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= @@ -211,8 +211,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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -222,8 +222,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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= @@ -236,8 +236,8 @@ 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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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= @@ -247,8 +247,8 @@ 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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= 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= @@ -260,8 +260,8 @@ 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -274,16 +274,16 @@ 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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.283.0 h1:0lkp8u0MPwJVHqRL+nJlMAoZVVzbmiXmFHXMOTmSPik= -google.golang.org/api v0.283.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI= +google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ= 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-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE= -google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY= +google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/web/package-lock.json b/web/package-lock.json index 8d925f8c..f8d2d489 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,14 +12,14 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "*", + "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^3.0.5", - "react": "*", - "react-dom": "*", + "react": "latest", + "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -2778,9 +2778,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2801,9 +2801,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", "cpu": [ "arm" ], @@ -2815,9 +2815,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", "cpu": [ "arm64" ], @@ -2829,9 +2829,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", "cpu": [ "arm64" ], @@ -2843,9 +2843,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", "cpu": [ "x64" ], @@ -2857,9 +2857,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", "cpu": [ "arm64" ], @@ -2871,9 +2871,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", "cpu": [ "x64" ], @@ -2885,13 +2885,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2899,13 +2902,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2913,13 +2919,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2927,13 +2936,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2941,13 +2953,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2955,13 +2970,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2969,13 +2987,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2983,13 +3004,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2997,13 +3021,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3011,13 +3038,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3025,13 +3055,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3039,13 +3072,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3053,13 +3089,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3067,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", "cpu": [ "x64" ], @@ -3081,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", "cpu": [ "arm64" ], @@ -3095,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", "cpu": [ "arm64" ], @@ -3109,9 +3148,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", "cpu": [ "ia32" ], @@ -3123,9 +3162,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", "cpu": [ "x64" ], @@ -3137,9 +3176,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", "cpu": [ "x64" ], @@ -3219,9 +3258,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3254,9 +3293,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "peer": true, "dependencies": { @@ -3321,9 +3360,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -3614,9 +3653,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3708,9 +3747,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3832,9 +3871,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -4269,9 +4308,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.362", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.362.tgz", - "integrity": "sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==", + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", "dev": true, "license": "ISC" }, @@ -4380,9 +4419,9 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", "dev": true, "license": "MIT", "dependencies": { @@ -4690,9 +4729,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.13.0.tgz", + "integrity": "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5245,18 +5284,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -5536,9 +5578,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5911,6 +5953,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6300,10 +6358,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6791,9 +6859,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -7109,9 +7177,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", - "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7286,24 +7354,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/react-i18next": { @@ -7341,9 +7409,9 @@ } }, "node_modules/react-is": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7516,9 +7584,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7637,13 +7705,13 @@ } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -7653,31 +7721,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" } }, @@ -7859,15 +7927,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8076,19 +8144,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8098,16 +8167,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -8325,9 +8394,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -8467,18 +8536,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -8751,9 +8820,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -8975,9 +9044,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz", - "integrity": "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { From 74240328dc00c66f83c04747f58b3937936f17ee Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 21:21:48 -0400 Subject: [PATCH 45/46] Changelog, constant time compare --- docs/releases.md | 1 + user/manager.go | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index a3c7db5c..2b97796d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1967,6 +1967,7 @@ since I do have to reset emails on a regular basis. * Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG * `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address +* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution) ### ntfy Android v1.25.x (UNRELEASED) diff --git a/user/manager.go b/user/manager.go index ea99ede2..ba2c77d3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -2,6 +2,7 @@ package user import ( + "crypto/subtle" "database/sql" "encoding/json" "errors" @@ -680,7 +681,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { // to sync subscriptions/settings across devices. Without this, an // auth-default-access of "deny-all" locks the user out of their own sync // topic (no ACL entry is created for it at user creation). See #733. - if user != nil && user.SyncTopic != "" && topic == user.SyncTopic { + if user != nil && user.SyncTopic != "" && subtle.ConstantTimeCompare([]byte(topic), []byte(user.SyncTopic)) == 1 { return nil } username := Everyone From ab1e170a20fdd4325a576867f0da16d77887bc00 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 22 Jun 2026 21:43:29 -0400 Subject: [PATCH 46/46] Fix S3 scheme backwards compat --- docs/releases.md | 1 + s3/client_test.go | 21 +++++++++++++++++++++ s3/util.go | 11 ++++++----- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 2b97796d..8eef6daf 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1968,6 +1968,7 @@ since I do have to reset emails on a regular basis. * Generate access tokens, IDs, and magic-link tokens with a cryptographically secure RNG (`crypto/rand`) instead of a clock-seeded PRNG * `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address * Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution) +* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution) ### ntfy Android v1.25.x (UNRELEASED) diff --git a/s3/client_test.go b/s3/client_test.go index de754cf7..b8a3f097 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -52,6 +52,27 @@ func TestParseURL_EndpointHTTP(t *testing.T) { require.True(t, cfg.PathStyle) } +func TestParseURL_EndpointNoScheme(t *testing.T) { + // A bare host:port endpoint (no scheme) must default to https for backward compatibility. + // Without this, url.Parse treats the host as the scheme ("localhost:9000" -> scheme "localhost"). + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=localhost:9000") + require.Nil(t, err) + require.Equal(t, "https", cfg.Scheme) + require.Equal(t, "localhost:9000", cfg.Endpoint) + require.True(t, cfg.PathStyle) + require.Equal(t, "https://localhost:9000/my-bucket", cfg.BucketURL()) +} + +func TestParseURL_EndpointNoSchemeHostname(t *testing.T) { + // A dotted hostname with a port and no scheme must also default to https + // ("minio.example.com:9000" must not become scheme "minio.example.com"). + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=minio.example.com:9000") + require.Nil(t, err) + require.Equal(t, "https", cfg.Scheme) + require.Equal(t, "minio.example.com:9000", cfg.Endpoint) + require.Equal(t, "https://minio.example.com:9000/my-bucket", cfg.BucketURL()) +} + func TestParseURL_EndpointTrailingSlash(t *testing.T) { cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/") require.Nil(t, err) diff --git a/s3/util.go b/s3/util.go index 25dbe09f..b759fd19 100644 --- a/s3/util.go +++ b/s3/util.go @@ -74,12 +74,13 @@ func ParseURL(s3URL string) (*Config, error) { var endpoint string var pathStyle bool if endpointParam != "" { - // Custom endpoint: strip scheme prefix to extract host[:port] - uep, err := url.Parse(endpointParam) - if err != nil { - return nil, fmt.Errorf("s3: invalid endpoint URL: %w", err) + // Custom endpoint: derive the scheme from the prefix and strip it to extract host[:port]. + // Default to https for backward compatibility, including bare "host:port" endpoints (no + // scheme) -- url.Parse would otherwise misread the host before the port colon as the scheme. + scheme = "https" + if strings.HasPrefix(endpointParam, "http://") { + scheme = "http" } - scheme = uep.Scheme ep := strings.TrimRight(endpointParam, "/") ep = strings.TrimPrefix(ep, "https://") ep = strings.TrimPrefix(ep, "http://")