1
0

9 کامیت‌ها 14df6462df ... 65050ef4dc

نویسنده SHA1 پیام تاریخ
  binwiederhier 65050ef4dc Fix server crash (nil pointer panic) when subscriber disconnects during publish 1 ماه پیش
  binwiederhier 3647d3975c Fix panic in handleSubscribeHTTP when client disconnects during publish 1 ماه پیش
  binwiederhier 06ea1f98ac Merge branch 'main' of github.com:binwiederhier/ntfy 1 ماه پیش
  binwiederhier 2827df26ee Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 1 ماه پیش
  binwiederhier fe6ee1efa0 Web: Show red notification dot on favicon when there are unread messages 1 ماه پیش
  Yaron Shahrabani 7b0eb3d467 Translated using Weblate (Hebrew) 1 ماه پیش
  Yaron Shahrabani 325983deaf Translated using Weblate (Hebrew) 1 ماه پیش
  Yaron Shahrabani bfb47c4046 Added translation using Weblate (Hebrew) 1 ماه پیش
  Kachelkaiser ad334178de Translated using Weblate (German) 1 ماه پیش
7فایلهای تغییر یافته به همراه282 افزوده شده و 27 حذف شده
  1. 7 5
      docs/releases.md
  2. 12 5
      server/server.go
  3. 128 0
      server/server_test.go
  4. 1 1
      web/public/static/langs/de.json
  5. 52 0
      web/public/static/langs/he.json
  6. 79 0
      web/src/app/utils.js
  7. 3 16
      web/src/components/App.jsx

+ 7 - 5
docs/releases.md

@@ -1685,19 +1685,21 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
 
 **Features:**
 
-* Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
+* Server: Support templating in the priority field ([#1426](https://github.com/binwiederhier/ntfy/issues/1426), thanks to [@seantomburke](https://github.com/seantomburke) for reporting)
+* Web: Show red notification dot on favicon when there are unread messages ([#1017](https://github.com/binwiederhier/ntfy/issues/1017), thanks to [@ad-si](https://github.com/ad-si) for reporting)
 
 **Bug fixes + maintenance:**
 
+* Server: Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
+* Server: Fix server crash (nil pointer panic) when subscriber disconnects during publish ([#1598](https://github.com/binwiederhier/ntfy/pull/1598))
+* Server: Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
+* Server: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
 * Web: Fix `clear=true` on action buttons not clearing the notification ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
-* Fix crash when commit string is shorter than 7 characters in non-GitHub-Action builds ([#1493](https://github.com/binwiederhier/ntfy/issues/1493), thanks to [@cyrinux](https://github.com/cyrinux) for reporting)
-* Fix log spam from `http: response.WriteHeader on hijacked connection` for WebSocket errors ([#1362](https://github.com/binwiederhier/ntfy/issues/1362), thanks to [@bonfiresh](https://github.com/bonfiresh) for reporting)
 * Web: Fix Markdown message line height to match plain text (1.5 instead of 1.2) ([#1139](https://github.com/binwiederhier/ntfy/issues/1139), thanks to [@etfz](https://github.com/etfz) for reporting)
 * Web: Fix long lines (e.g. JSON) being truncated by adding horizontal scroll ([#1363](https://github.com/binwiederhier/ntfy/issues/1363), thanks to [@v3DJG6GL](https://github.com/v3DJG6GL) for reporting)
 * Web: Fix Windows notification icon being cut off ([#884](https://github.com/binwiederhier/ntfy/issues/884), thanks to [@ZhangTianrong](https://github.com/ZhangTianrong) for reporting)
 * Web: Use full URL in curl example on empty topic pages ([#1435](https://github.com/binwiederhier/ntfy/issues/1435), [#1535](https://github.com/binwiederhier/ntfy/pull/1535), thanks to [@elmatadoor](https://github.com/elmatadoor) for reporting and [@jjasghar](https://github.com/jjasghar) for the PR)
 * Web: Add validation feedback for service URL when adding user ([#1566](https://github.com/binwiederhier/ntfy/issues/1566), thanks to [@jermanuts](https://github.com/jermanuts))
-* Refactor: Use `slices.Contains` from stdlib to simplify code ([#1406](https://github.com/binwiederhier/ntfy/pull/1406), thanks to [@tanhuaan](https://github.com/tanhuaan))
 * Docs: Remove obsolete `version` field from docker-compose examples ([#1333](https://github.com/binwiederhier/ntfy/issues/1333), thanks to [@seals187](https://github.com/seals187) for reporting and [@cyb3rko](https://github.com/cyb3rko) for fixing)
 * Docs: Fix Kustomize config in installation docs ([#1367](https://github.com/binwiederhier/ntfy/issues/1367), thanks to [@toby-griffiths](https://github.com/toby-griffiths))
-* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)
+* Docs: Use SVG F-Droid badge and add app store badges to README ([#1170](https://github.com/binwiederhier/ntfy/issues/1170), thanks to [@PanderMusubi](https://github.com/PanderMusubi) for reporting)

+ 12 - 5
server/server.go

@@ -1458,12 +1458,16 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 		return err
 	}
 	var wlock sync.Mutex
+	var closed bool
 	defer func() {
-		// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
-		// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
-		// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
-		// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
-		wlock.TryLock()
+		// This blocks until any in-flight sub() call finishes writing/flushing the response writer,
+		// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
+		// from writing to a response writer that has been cleaned up after the handler returns.
+		// See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889
+		// and https://github.com/binwiederhier/ntfy/pull/1598.
+		wlock.Lock()
+		closed = true
+		wlock.Unlock()
 	}()
 	sub := func(v *visitor, msg *message) error {
 		if !filters.Pass(msg) {
@@ -1475,6 +1479,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
 		}
 		wlock.Lock()
 		defer wlock.Unlock()
+		if closed {
+			return nil
+		}
 		if _, err := w.Write([]byte(m)); err != nil {
 			return err
 		}

+ 128 - 0
server/server_test.go

@@ -3901,6 +3901,134 @@ func (m *mockResponseWriter) WriteHeader(statusCode int) {
 	m.writeHeaderHit = true
 }
 
+// closableResponseWriter simulates a real HTTP response writer that becomes invalid
+// after the handler returns. In production, Go's HTTP server calls finishRequest() after
+// the handler returns, which nils out the underlying bufio.Writer. Any subsequent Flush()
+// from a straggler Publish goroutine causes a nil pointer panic. This mock tracks whether
+// any Write or Flush occurred after the handler returned (i.e. after Close was called).
+type closableResponseWriter struct {
+	header          http.Header
+	mu              sync.Mutex
+	closed          bool
+	wroteAfterClose atomic.Bool
+}
+
+func newClosableResponseWriter() *closableResponseWriter {
+	return &closableResponseWriter{
+		header: make(http.Header),
+	}
+}
+
+func (w *closableResponseWriter) Header() http.Header {
+	return w.header
+}
+
+func (w *closableResponseWriter) Write(b []byte) (int, error) {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	if w.closed {
+		w.wroteAfterClose.Store(true)
+		return 0, errors.New("write after handler returned")
+	}
+	return len(b), nil
+}
+
+func (w *closableResponseWriter) WriteHeader(statusCode int) {}
+
+func (w *closableResponseWriter) Flush() {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	if w.closed {
+		w.wroteAfterClose.Store(true)
+	}
+}
+
+// Close simulates Go's HTTP server cleaning up the response writer after the handler returns.
+func (w *closableResponseWriter) Close() {
+	w.mu.Lock()
+	defer w.mu.Unlock()
+	w.closed = true
+}
+
+func TestServer_SubscribeHTTP_NoWriteAfterHandlerReturn(t *testing.T) {
+	// This test reproduces the panic from https://github.com/binwiederhier/ntfy/issues/338:
+	//
+	//   panic: runtime error: invalid memory address or nil pointer dereference
+	//   bufio.(*Writer).Flush(...)
+	//   net/http.(*response).Flush(...)
+	//   server.(*Server).handleSubscribeHTTP.func2(...)
+	//   server.(*topic).Publish.func1.1(...)
+	//
+	// The race: topic.Publish() copies the subscriber list and calls each subscriber in its own
+	// goroutine. If the subscriber disconnects, the handler returns and Go's HTTP server cleans up
+	// the response writer. But a Publish goroutine that copied the subscriber list BEFORE
+	// Unsubscribe may still call sub() AFTER the handler returns.
+	//
+	// This test deterministically reproduces the scenario by:
+	// 1. Subscribing via handleSubscribeHTTP (which registers a sub closure on the topic)
+	// 2. Copying the subscriber function from the topic (simulating what topic.Publish does)
+	// 3. Cancelling the subscription and waiting for the handler to fully return
+	// 4. Calling the copied subscriber function AFTER the handler has returned
+	// 5. Checking that no write/flush occurred on the (now-invalid) response writer
+	//
+	// Without the wlock+closed fix, calling the subscriber after the handler returns writes to
+	// the closed response writer (which in production causes a nil pointer panic on Flush).
+	// With the fix, the subscriber sees closed=true and returns without writing.
+	t.Parallel()
+	s := newTestServer(t, newTestConfig(t))
+
+	rw := newClosableResponseWriter()
+	ctx, cancel := context.WithCancel(context.Background())
+	req, err := http.NewRequestWithContext(ctx, "GET", "/mytopic/json", nil)
+	require.Nil(t, err)
+	req.RemoteAddr = "9.9.9.9:1234"
+
+	// Start the subscribe handler (blocks until context is cancelled)
+	handlerDone := make(chan struct{})
+	go func() {
+		s.handle(rw, req)
+		close(handlerDone)
+	}()
+	time.Sleep(100 * time.Millisecond) // Wait for subscription to be registered
+
+	// Grab a copy of the subscriber function from the topic, exactly as topic.Publish() does
+	// via subscribersCopy(). This must happen BEFORE cancel/Unsubscribe removes the subscriber.
+	s.mu.RLock()
+	tp := s.topics["mytopic"]
+	s.mu.RUnlock()
+	require.NotNil(t, tp)
+	subscribersCopy := tp.subscribersCopy()
+	require.Equal(t, 1, len(subscribersCopy))
+
+	var copiedSub subscriber
+	for _, sub := range subscribersCopy {
+		copiedSub = sub.subscriber
+	}
+
+	// Cancel the subscription and wait for the handler to fully return.
+	// At this point, the deferred cleanup in handleSubscribeHTTP runs:
+	// - With fix: wlock.Lock() waits for in-flight sub(), sets closed=true, wlock.Unlock()
+	// - Without fix: nothing prevents future sub() calls from writing
+	cancel()
+	<-handlerDone
+
+	// Simulate Go's HTTP server cleaning up the response writer after the handler returns.
+	// In production, this is finishRequest() which nils out the bufio.Writer.
+	rw.Close()
+
+	// Now call the copied subscriber function, simulating a straggler Publish goroutine
+	// that copied the subscriber list before Unsubscribe ran. In production, this is exactly
+	// how the panic occurs: the goroutine spawned by topic.Publish calls sub() after the
+	// handler has already returned and Go has cleaned up the response writer.
+	v := newVisitor(s.config, s.messageCache, s.userManager, netip.MustParseAddr("9.9.9.9"), nil)
+	msg := newDefaultMessage("mytopic", "straggler message")
+	_ = copiedSub(v, msg)
+
+	require.False(t, rw.wroteAfterClose.Load(),
+		"sub() wrote to the response writer after the handler returned; "+
+			"in production this causes a nil pointer panic in bufio.(*Writer).Flush()")
+}
+
 func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
 	// Test that handleError does not call WriteHeader for WebSocket errors wrapped
 	// with errWebSocketPostUpgrade (indicating the connection was hijacked)

+ 1 - 1
web/public/static/langs/de.json

@@ -73,7 +73,7 @@
     "publish_dialog_tags_placeholder": "Komma-getrennte Liste von Tags, z.B. Warnung, srv1-Backup",
     "publish_dialog_priority_label": "Priorität",
     "publish_dialog_filename_label": "Dateiname",
-    "publish_dialog_title_placeholder": "Benachrichtigungs-Titel, z.B. CPU-Last-Warnung",
+    "publish_dialog_title_placeholder": "Benachrichtigungstitel, z. B. Speicherplatzwarnung",
     "publish_dialog_tags_label": "Tags",
     "publish_dialog_click_label": "Klick-URL",
     "publish_dialog_click_placeholder": "URL die geöffnet werden soll, wenn die Benachrichtigung angeklickt wird",

+ 52 - 0
web/public/static/langs/he.json

@@ -0,0 +1,52 @@
+{
+    "common_cancel": "ביטול",
+    "common_save": "שמירה",
+    "common_add": "הוספה",
+    "common_back": "חזרה",
+    "common_copy_to_clipboard": "העתקה ללוח הגזירים",
+    "signup_title": "יצירת חשבון ntfy",
+    "signup_form_username": "שם משתמש",
+    "signup_form_password": "סיסמה",
+    "signup_form_confirm_password": "אישור סיסמה",
+    "signup_form_button_submit": "הרשמה",
+    "signup_form_toggle_password_visibility": "הצגת/הסתרת סיסמה",
+    "signup_already_have_account": "כבר יש לך חשבון? אפשר להיכנס איתו!",
+    "signup_disabled": "הרשמה כבויה",
+    "signup_error_username_taken": "שם המשתמש {{username}} כבר תפוס",
+    "signup_error_creation_limit_reached": "הגעת למגבלת יצירת חשבונות",
+    "login_title": "כניסה לחשבון ה־ntfy שלך",
+    "login_form_button_submit": "כניסה",
+    "login_link_signup": "הרשמה",
+    "login_disabled": "הכניסה מושבתת",
+    "action_bar_show_menu": "הצגת תפריט",
+    "action_bar_logo_alt": "הלוגו של ntfy",
+    "action_bar_settings": "הגדרות",
+    "action_bar_account": "חשבון",
+    "action_bar_change_display_name": "החלפת שם תצוגה",
+    "action_bar_reservation_add": "שימור נושא",
+    "action_bar_reservation_edit": "החלפת מצב שימור",
+    "action_bar_reservation_delete": "הסרת שימור",
+    "action_bar_reservation_limit_reached": "הגעת למגבלה",
+    "action_bar_send_test_notification": "שליחת התראת ניסוי",
+    "action_bar_clear_notifications": "לפנות את כל ההתראות",
+    "action_bar_mute_notifications": "השתקת התראות",
+    "action_bar_unmute_notifications": "ביטול השתקת התראות",
+    "action_bar_unsubscribe": "ביטול מינוי",
+    "notifications_list_item": "התראה",
+    "notifications_mark_read": "סימון כנקראה",
+    "notifications_delete": "מחיקה",
+    "notifications_copied_to_clipboard": "הועתקה ללוח הגזירים",
+    "notifications_tags": "תגיות",
+    "notifications_priority_x": "עדיפות {{priority}}",
+    "notifications_new_indicator": "התראה חדשה",
+    "notifications_attachment_copy_url_button": "העתקת כתובת",
+    "notifications_attachment_open_title": "מעבר אל {{url}}",
+    "notifications_attachment_open_button": "פתיחת צרופה",
+    "notifications_attachment_link_expires": "תוקף הקישור פג ב־{{date}}",
+    "notifications_attachment_link_expired": "תוקף קישור ההורדה פג",
+    "notifications_actions_failed_notification": "פעולה לא מוצלחת",
+    "notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.",
+    "notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.",
+    "notifications_none_for_any_title": "לא קיבלת התראות כלל.",
+    "notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין."
+}

+ 79 - 0
web/src/app/utils.js

@@ -8,6 +8,7 @@ import pop from "../sounds/pop.mp3";
 import popSwoosh from "../sounds/pop-swoosh.mp3";
 import config from "./config";
 import emojisMapped from "./emojisMapped";
+import { THEME } from "./Prefs";
 
 export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
@@ -274,6 +275,84 @@ export const urlB64ToUint8Array = (base64String) => {
   return outputArray;
 };
 
+export const darkModeEnabled = (prefersDarkMode, themePreference) => {
+  switch (themePreference) {
+    case THEME.DARK:
+      return true;
+
+    case THEME.LIGHT:
+      return false;
+
+    case THEME.SYSTEM:
+    default:
+      return prefersDarkMode;
+  }
+};
+
+// Canvas-based favicon with a red notification dot when there are unread messages
+let faviconCanvas;
+let faviconOriginalIcon;
+
+const loadFaviconIcon = () =>
+  new Promise((resolve) => {
+    if (faviconOriginalIcon) {
+      resolve(faviconOriginalIcon);
+      return;
+    }
+    const img = new Image();
+    img.onload = () => {
+      faviconOriginalIcon = img;
+      resolve(img);
+    };
+    img.onerror = () => resolve(null);
+    // Use PNG instead of ICO — .ico files can't be reliably drawn to canvas in all browsers
+    img.src = "/static/images/ntfy.png";
+  });
+
+export const updateFavicon = async (count) => {
+  const size = 32;
+  const img = await loadFaviconIcon();
+  if (!img) {
+    return;
+  }
+
+  if (!faviconCanvas) {
+    faviconCanvas = document.createElement("canvas");
+    faviconCanvas.width = size;
+    faviconCanvas.height = size;
+  }
+
+  const ctx = faviconCanvas.getContext("2d");
+  ctx.clearRect(0, 0, size, size);
+  ctx.drawImage(img, 0, 0, size, size);
+
+  if (count > 0) {
+    const dotRadius = 5;
+    const borderWidth = 2;
+    const dotX = size - dotRadius - borderWidth + 1;
+    const dotY = size - dotRadius - borderWidth + 1;
+
+    // Transparent border: erase a ring around the dot so the icon doesn't bleed into it
+    ctx.save();
+    ctx.globalCompositeOperation = "destination-out";
+    ctx.beginPath();
+    ctx.arc(dotX, dotY, dotRadius + borderWidth, 0, 2 * Math.PI);
+    ctx.fill();
+    ctx.restore();
+
+    // Red dot
+    ctx.beginPath();
+    ctx.arc(dotX, dotY, dotRadius, 0, 2 * Math.PI);
+    ctx.fillStyle = "#dc3545";
+    ctx.fill();
+  }
+
+  const link = document.querySelector("link[rel='icon']");
+  if (link) {
+    link.href = faviconCanvas.toDataURL("image/png");
+  }
+};
+
 export const copyToClipboard = (text) => {
   if (navigator.clipboard && window.isSecureContext) {
     return navigator.clipboard.writeText(text);

+ 3 - 16
web/src/components/App.jsx

@@ -11,7 +11,7 @@ import ActionBar from "./ActionBar";
 import Preferences from "./Preferences";
 import subscriptionManager from "../app/SubscriptionManager";
 import userManager from "../app/UserManager";
-import { expandUrl, getKebabCaseLangStr } from "../app/utils";
+import { expandUrl, getKebabCaseLangStr, darkModeEnabled, updateFavicon } from "../app/utils";
 import ErrorBoundary from "./ErrorBoundary";
 import routes from "./routes";
 import { useAccountListener, useBackgroundProcesses, useConnectionListeners, useWebPushTopics } from "./hooks";
@@ -21,7 +21,7 @@ import Login from "./Login";
 import Signup from "./Signup";
 import Account from "./Account";
 import initI18n from "../app/i18n"; // Translations!
-import prefs, { THEME } from "../app/Prefs";
+import prefs from "../app/Prefs";
 import RTLCacheProvider from "./RTLCacheProvider";
 import session from "../app/Session";
 
@@ -29,20 +29,6 @@ initI18n();
 
 export const AccountContext = createContext(null);
 
-const darkModeEnabled = (prefersDarkMode, themePreference) => {
-  switch (themePreference) {
-    case THEME.DARK:
-      return true;
-
-    case THEME.LIGHT:
-      return false;
-
-    case THEME.SYSTEM:
-    default:
-      return prefersDarkMode;
-  }
-};
-
 const App = () => {
   const { i18n } = useTranslation();
   const languageDir = i18n.dir();
@@ -97,6 +83,7 @@ const App = () => {
 const updateTitle = (newNotificationsCount) => {
   document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy";
   window.navigator.setAppBadge?.(newNotificationsCount);
+  updateFavicon(newNotificationsCount);
 };
 
 const Layout = () => {