diff --git a/cmd/serve.go b/cmd/serve.go index 75d5de49..23e50ac0 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "heckel.io/ntfy/log" + "io/fs" "math" "net" "os" @@ -35,6 +36,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), @@ -99,6 +101,7 @@ func execServe(c *cli.Context) error { listenHTTP := c.String("listen-http") listenHTTPS := c.String("listen-https") listenUnix := c.String("listen-unix") + listenUnixMode := c.Int("listen-unix-mode") keyFile := c.String("key-file") certFile := c.String("cert-file") firebaseKeyFile := c.String("firebase-key-file") @@ -219,6 +222,7 @@ func execServe(c *cli.Context) error { conf.ListenHTTP = listenHTTP conf.ListenHTTPS = listenHTTPS conf.ListenUnix = listenUnix + conf.ListenUnixMode = fs.FileMode(listenUnixMode) conf.KeyFile = keyFile conf.CertFile = certFile conf.FirebaseKeyFile = firebaseKeyFile diff --git a/docs/config.md b/docs/config.md index 4ff21dd4..6908c469 100644 --- a/docs/config.md +++ b/docs/config.md @@ -875,6 +875,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | | `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | | `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | +| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | | `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | | `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | | `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). | diff --git a/docs/releases.md b/docs/releases.md index 90c479a5..3ae44d05 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -9,14 +9,17 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** * Subscriptions can now have a display name ([#313](https://github.com/binwiederhier/ntfy/issues/313), thanks to [@wunter8](https://github.com/wunter8)) -* Polling is now done with since= API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) +* Display name for UnifiedPush subscriptions ([#355](https://github.com/binwiederhier/ntfy/issues/355), thanks to [@wunter8](https://github.com/wunter8)) +* Polling is now done with `since=` API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165)) * Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket) +* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8)) **Bugs:** * Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8)) * Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8)) * Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting) +* Web: Switched "Pop" and "Pop Swoosh" sounds ([#352](https://github.com/binwiederhier/ntfy/issues/352), thanks to [@coma-toast](https://github.com/coma-toast) for reporting) **Additional translations:** @@ -31,11 +34,13 @@ Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up s **Features:** * Subscription display name for the web app ([#348](https://github.com/binwiederhier/ntfy/pull/348)) +* Allow setting socket permissions via `--listen-unix-mode` ([#356](https://github.com/binwiederhier/ntfy/pull/356), thanks to [@koro666](https://github.com/koro666)) **Bugs:** * `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting) * Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting) +* Delete expired attachments based on mod time instead of DB entry to avoid races (no ticket) **Documentation:** diff --git a/server/config.go b/server/config.go index e34eefb8..e117da88 100644 --- a/server/config.go +++ b/server/config.go @@ -1,6 +1,7 @@ package server import ( + "io/fs" "time" ) @@ -52,6 +53,7 @@ type Config struct { ListenHTTP string ListenHTTPS string ListenUnix string + ListenUnixMode fs.FileMode KeyFile string CertFile string FirebaseKeyFile string @@ -105,6 +107,7 @@ func NewConfig() *Config { ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", ListenUnix: "", + ListenUnixMode: 0, KeyFile: "", CertFile: "", FirebaseKeyFile: "", diff --git a/server/file_cache.go b/server/file_cache.go index ad4961cc..88de935d 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -2,16 +2,18 @@ package server import ( "errors" + "fmt" "heckel.io/ntfy/util" "io" "os" "path/filepath" "regexp" "sync" + "time" ) var ( - fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) + fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength)) errInvalidFileID = errors.New("invalid file ID") errFileExists = errors.New("file exists") ) @@ -88,6 +90,25 @@ func (c *fileCache) Remove(ids ...string) error { return nil } +// Expired returns a list of file IDs for expired files +func (c *fileCache) Expired(olderThan time.Time) ([]string, error) { + entries, err := os.ReadDir(c.dir) + if err != nil { + return nil, err + } + var ids []string + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(olderThan) && fileIDRegex.MatchString(e.Name()) { + ids = append(ids, e.Name()) + } + } + return ids, nil +} + func (c *fileCache) Size() int64 { c.mu.Lock() defer c.mu.Unlock() diff --git a/server/file_cache_test.go b/server/file_cache_test.go index 36d1d1a3..971cff1d 100644 --- a/server/file_cache_test.go +++ b/server/file_cache_test.go @@ -8,6 +8,7 @@ import ( "os" "strings" "testing" + "time" ) var ( @@ -16,10 +17,10 @@ var ( func TestFileCache_Write_Success(t *testing.T) { dir, c := newTestFileCache(t) - size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999)) + size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) require.Nil(t, err) require.Equal(t, int64(11), size) - require.Equal(t, "normal file", readFile(t, dir+"/abc")) + require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) require.Equal(t, int64(11), c.Size()) require.Equal(t, int64(10229), c.Remaining()) } @@ -27,18 +28,18 @@ func TestFileCache_Write_Success(t *testing.T) { func TestFileCache_Write_Remove_Success(t *testing.T) { dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024) for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999))) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) require.Nil(t, err) require.Equal(t, int64(999), size) } require.Equal(t, int64(9990), c.Size()) require.Equal(t, int64(250), c.Remaining()) - require.FileExists(t, dir+"/abc1") - require.FileExists(t, dir+"/abc5") + require.FileExists(t, dir+"/abcdefghijk1") + require.FileExists(t, dir+"/abcdefghijk5") - require.Nil(t, c.Remove("abc1", "abc5")) - require.NoFileExists(t, dir+"/abc1") - require.NoFileExists(t, dir+"/abc5") + require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) + require.NoFileExists(t, dir+"/abcdefghijk1") + require.NoFileExists(t, dir+"/abcdefghijk5") require.Equal(t, int64(7992), c.Size()) require.Equal(t, int64(2248), c.Remaining()) } @@ -46,27 +47,50 @@ func TestFileCache_Write_Remove_Success(t *testing.T) { func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) { dir, c := newTestFileCache(t) for i := 0; i < 10; i++ { - size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray)) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) require.Nil(t, err) require.Equal(t, int64(1024), size) } - _, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray)) + _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc11") + require.NoFileExists(t, dir+"/abcdefghijkX") } func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) { dir, c := newTestFileCache(t) - _, err := c.Write("abc", bytes.NewReader(make([]byte, 1025))) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1025))) require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc") + require.NoFileExists(t, dir+"/abcdefghijkl") } func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { dir, c := newTestFileCache(t) - _, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abc") + require.NoFileExists(t, dir+"/abcdefghijkl") +} + +func TestFileCache_RemoveExpired(t *testing.T) { + dir, c := newTestFileCache(t) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001))) + require.Nil(t, err) + _, err = c.Write("notdeleted12", bytes.NewReader(make([]byte, 1001))) + require.Nil(t, err) + + modTime := time.Now().Add(-1 * 4 * time.Hour) + require.Nil(t, os.Chtimes(dir+"/abcdefghijkl", modTime, modTime)) + + olderThan := time.Now().Add(-1 * 3 * time.Hour) + ids, err := c.Expired(olderThan) + require.Nil(t, err) + require.Equal(t, []string{"abcdefghijkl"}, ids) + require.Nil(t, c.Remove(ids...)) + require.NoFileExists(t, dir+"/abcdefghijkl") + require.FileExists(t, dir+"/notdeleted12") + + ids, err = c.Expired(olderThan) + require.Nil(t, err) + require.Empty(t, ids) } func newTestFileCache(t *testing.T) (dir string, cache *fileCache) { diff --git a/server/message_cache.go b/server/message_cache.go index f6fba96d..2e9c577e 100644 --- a/server/message_cache.go +++ b/server/message_cache.go @@ -85,7 +85,6 @@ const ( selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?` - selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` ) // Schema management queries @@ -409,26 +408,6 @@ func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) { return size, nil } -func (c *messageCache) AttachmentsExpired() ([]string, error) { - rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix()) - if err != nil { - return nil, err - } - defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return ids, nil -} - func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) diff --git a/server/message_cache_test.go b/server/message_cache_test.go index b68fc330..23c080d4 100644 --- a/server/message_cache_test.go +++ b/server/message_cache_test.go @@ -344,10 +344,6 @@ func testCacheAttachments(t *testing.T, c *messageCache) { size, err = c.AttachmentBytesUsed("5.6.7.8") require.Nil(t, err) require.Equal(t, int64(0), size) - - ids, err := c.AttachmentsExpired() - require.Nil(t, err) - require.Equal(t, []string{"m1"}, ids) } func TestSqliteCache_Migration_From0(t *testing.T) { diff --git a/server/server.go b/server/server.go index 7026dfb9..94f35801 100644 --- a/server/server.go +++ b/server/server.go @@ -204,9 +204,18 @@ func (s *Server) Run() error { os.Remove(s.config.ListenUnix) s.unixListener, err = net.Listen("unix", s.config.ListenUnix) if err != nil { + s.mu.Unlock() errChan <- err return } + defer s.unixListener.Close() + if s.config.ListenUnixMode > 0 { + if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil { + s.mu.Unlock() + errChan <- err + return + } + } s.mu.Unlock() httpServer := &http.Server{Handler: mux} errChan <- httpServer.Serve(s.unixListener) @@ -1107,8 +1116,9 @@ func (s *Server) updateStatsAndPrune() { log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors) // Delete expired attachments - if s.fileCache != nil { - ids, err := s.messageCache.AttachmentsExpired() + if s.fileCache != nil && s.config.AttachmentExpiryDuration > 0 { + olderThan := time.Now().Add(-1 * s.config.AttachmentExpiryDuration) + ids, err := s.fileCache.Expired(olderThan) if err != nil { log.Warn("Error retrieving expired attachments: %s", err.Error()) } else if len(ids) > 0 { diff --git a/server/server.yml b/server/server.yml index e6d4c9e8..245bc4da 100644 --- a/server/server.yml +++ b/server/server.yml @@ -26,6 +26,7 @@ # This can be useful to avoid port issues on local systems, and to simplify permissions. # # listen-unix: +# listen-unix-mode: # Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set. # diff --git a/server/server_test.go b/server/server_test.go index 1634d8d9..d68cfa11 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -66,6 +66,8 @@ func TestServer_PublishWithFirebase(t *testing.T) { msg1 := toMessage(t, response.Body.String()) require.NotEmpty(t, msg1.ID) require.Equal(t, "my first message", msg1.Message) + + time.Sleep(100 * time.Millisecond) // Firebase publishing happens require.Equal(t, 1, len(sender.Messages())) require.Equal(t, "my first message", sender.Messages()[0].Data["message"]) require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.Aps.Alert.Body) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json new file mode 100644 index 00000000..9da0e06b --- /dev/null +++ b/web/public/static/langs/zh_Hant.json @@ -0,0 +1,56 @@ +{ + "action_bar_logo_alt": "ntfy 標識", + "action_bar_unsubscribe": "取消訂閱", + "action_bar_toggle_mute": "通知靜音/解除通知靜音", + "action_bar_toggle_action_menu": "開啟/關閉操作選單", + "message_bar_type_message": "在這邊輸入訊息", + "alert_grant_description": "允許瀏覽器權限以顯示桌面通知。", + "alert_grant_button": "允許", + "notifications_list": "通知清單", + "notifications_list_item": "通知", + "notifications_mark_read": "標示已讀", + "notifications_attachment_image": "附加圖片", + "notifications_attachment_copy_url_title": "複製附件URL到剪貼板", + "notifications_attachment_copy_url_button": "複製URL", + "notifications_attachment_open_title": "前往 {{url}}", + "notifications_attachment_open_button": "開啟附件", + "notifications_attachment_link_expired": "下載連結已過期", + "notifications_attachment_file_video": "影片檔案", + "notifications_attachment_file_app": "Android 應用程式檔案", + "notifications_attachment_file_document": "其他文件", + "notifications_click_copy_url_title": "複製連結URL到剪貼板", + "notifications_click_copy_url_button": "複製連結", + "notifications_click_open_button": "開啟連結", + "notifications_actions_not_supported": "網頁程式無法支援該動作", + "notifications_actions_http_request_title": "傳送 HTTP {{method}} 到 {{url}}", + "notifications_none_for_topic_title": "尚未收到任何此主題的通知。", + "notifications_none_for_topic_description": "如要寄送通知到此主題,請使用 PUT 或 POST 到此主題URL。", + "notifications_none_for_any_title": "尚未收到任何通知。", + "action_bar_settings": "設定", + "action_bar_send_test_notification": "寄送測試通知", + "action_bar_clear_notifications": "清除所有通知", + "action_bar_show_menu": "顯示選單", + "nav_button_documentation": "文件", + "nav_button_publish_message": "發布通知", + "nav_button_muted": "通知已靜音", + "notifications_copied_to_clipboard": "複製到剪貼板", + "message_bar_publish": "發布訊息", + "message_bar_show_dialog": "顯示發布對話筐", + "message_bar_error_publishing": "無法發布通知", + "nav_topics_title": "訂閱主題", + "nav_button_all_notifications": "所有通知", + "nav_button_settings": "設定", + "nav_button_subscribe": "訂閱主題", + "nav_button_connecting": "連線中", + "alert_grant_title": "通知已關閉", + "alert_not_supported_title": "不支援通知", + "alert_not_supported_description": "瀏覽器不支援通知。", + "notifications_tags": "標籤", + "notifications_priority_x": "優先度 {{priority}}", + "notifications_new_indicator": "新通知", + "notifications_attachment_file_audio": "聲音檔案", + "notifications_delete": "刪除", + "notifications_attachment_link_expires": "連結已過期 {{date}}", + "notifications_attachment_file_image": "圖片檔案", + "notifications_actions_open_url_title": "前往 {{url}}" +} diff --git a/web/src/sounds/pop-swoosh.mp3 b/web/src/sounds/pop-swoosh.mp3 index 007ce4bd..3d5bf476 100644 Binary files a/web/src/sounds/pop-swoosh.mp3 and b/web/src/sounds/pop-swoosh.mp3 differ diff --git a/web/src/sounds/pop.mp3 b/web/src/sounds/pop.mp3 index 3d5bf476..007ce4bd 100644 Binary files a/web/src/sounds/pop.mp3 and b/web/src/sounds/pop.mp3 differ