From d09afd8b606b8295e4cc2801fdee1b9e5176d1ae Mon Sep 17 00:00:00 2001 From: brianchul Date: Tue, 28 Jun 2022 08:06:49 +0200 Subject: [PATCH 01/12] Added translation using Weblate (Chinese (Traditional)) --- web/public/static/langs/zh_Hant.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/zh_Hant.json diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/zh_Hant.json @@ -0,0 +1 @@ +{} From 691a77370ec8cea884de1dfca54eb96dd7f23e8b Mon Sep 17 00:00:00 2001 From: brianchul Date: Tue, 28 Jun 2022 06:09:21 +0000 Subject: [PATCH 02/12] Translated using Weblate (Chinese (Traditional)) Currently translated at 28.5% (54 of 189 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hant/ --- web/public/static/langs/zh_Hant.json | 57 +++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index 0967ef42..9da0e06b 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -1 +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}}" +} From 9f358d47938420717fe9d63dc669cbe7ab3f7991 Mon Sep 17 00:00:00 2001 From: Koro Date: Sun, 3 Jul 2022 15:07:57 -0400 Subject: [PATCH 03/12] Add socket mode to configuration struct. --- server/config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/config.go b/server/config.go index e34eefb8..90597f2a 100644 --- a/server/config.go +++ b/server/config.go @@ -1,12 +1,14 @@ package server import ( + "io/fs" "time" ) // Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" + DefaultListenUnixMode = 0777 DefaultCacheDuration = 12 * time.Hour DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultManagerInterval = time.Minute @@ -52,6 +54,7 @@ type Config struct { ListenHTTP string ListenHTTPS string ListenUnix string + ListenUnixMode fs.FileMode KeyFile string CertFile string FirebaseKeyFile string @@ -105,6 +108,7 @@ func NewConfig() *Config { ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", ListenUnix: "", + ListenUnixMode: DefaultListenUnixMode, KeyFile: "", CertFile: "", FirebaseKeyFile: "", From 89316487e365e48d8e36d4c8f2126dd5fd2e81e2 Mon Sep 17 00:00:00 2001 From: Koro Date: Sun, 3 Jul 2022 15:20:58 -0400 Subject: [PATCH 04/12] Add socket mode command-line option. --- cmd/serve.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/serve.go b/cmd/serve.go index 75d5de49..a1b0bd91 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -5,6 +5,7 @@ package cmd import ( "errors" "fmt" + "io/fs" "heckel.io/ntfy/log" "math" "net" @@ -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"}, Value: server.DefaultListenUnixMode, Usage: "file mode of unix socket"}), 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 From ed1673beeddf477a29392e2a976fa1210813f785 Mon Sep 17 00:00:00 2001 From: Koro Date: Sun, 3 Jul 2022 15:27:36 -0400 Subject: [PATCH 05/12] Set socket mode after creation. --- server/server.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 7026dfb9..1ffb85cb 100644 --- a/server/server.go +++ b/server/server.go @@ -174,7 +174,7 @@ func (s *Server) Run() error { listenStr += fmt.Sprintf(" %s[https]", s.config.ListenHTTPS) } if s.config.ListenUnix != "" { - listenStr += fmt.Sprintf(" %s[unix]", s.config.ListenUnix) + listenStr += fmt.Sprintf(" %s[unix/%04o]", s.config.ListenUnix, s.config.ListenUnixMode) } if s.config.SMTPServerListen != "" { listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) @@ -207,6 +207,11 @@ func (s *Server) Run() error { errChan <- err return } + if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil { + s.unixListener.Close() + errChan <- err + return + } s.mu.Unlock() httpServer := &http.Server{Handler: mux} errChan <- httpServer.Serve(s.unixListener) From 8532b5b7ea9716fc35a88b5e7f82e4050349f864 Mon Sep 17 00:00:00 2001 From: Koro Date: Sun, 3 Jul 2022 15:36:13 -0400 Subject: [PATCH 06/12] Update documentation. --- docs/config.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/config.md b/docs/config.md index 4ff21dd4..28a9ee35 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* | 0777 | File mode of the Unix socket | | `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). | From bf8077626ec8e150602c95dbc86ed370b958bd8b Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 3 Jul 2022 19:33:01 -0400 Subject: [PATCH 07/12] Permissions of unix socket --- cmd/serve.go | 4 ++-- docs/releases.md | 1 + server/config.go | 3 +-- server/server.go | 14 +++++++++----- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index a1b0bd91..23e50ac0 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -5,8 +5,8 @@ package cmd import ( "errors" "fmt" - "io/fs" "heckel.io/ntfy/log" + "io/fs" "math" "net" "os" @@ -36,7 +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"}, Value: server.DefaultListenUnixMode, Usage: "file mode of unix socket"}), + 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"}), diff --git a/docs/releases.md b/docs/releases.md index 90c479a5..1644e26e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -31,6 +31,7 @@ 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:** diff --git a/server/config.go b/server/config.go index 90597f2a..e117da88 100644 --- a/server/config.go +++ b/server/config.go @@ -8,7 +8,6 @@ import ( // Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" - DefaultListenUnixMode = 0777 DefaultCacheDuration = 12 * time.Hour DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultManagerInterval = time.Minute @@ -108,7 +107,7 @@ func NewConfig() *Config { ListenHTTP: DefaultListenHTTP, ListenHTTPS: "", ListenUnix: "", - ListenUnixMode: DefaultListenUnixMode, + ListenUnixMode: 0, KeyFile: "", CertFile: "", FirebaseKeyFile: "", diff --git a/server/server.go b/server/server.go index 1ffb85cb..ca0d6393 100644 --- a/server/server.go +++ b/server/server.go @@ -174,7 +174,7 @@ func (s *Server) Run() error { listenStr += fmt.Sprintf(" %s[https]", s.config.ListenHTTPS) } if s.config.ListenUnix != "" { - listenStr += fmt.Sprintf(" %s[unix/%04o]", s.config.ListenUnix, s.config.ListenUnixMode) + listenStr += fmt.Sprintf(" %s[unix]", s.config.ListenUnix) } if s.config.SMTPServerListen != "" { listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen) @@ -204,13 +204,17 @@ 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 } - if err := os.Chmod(s.config.ListenUnix, s.config.ListenUnixMode); err != nil { - s.unixListener.Close() - 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} From e874f3516ea370d15ee6b910159029faf3734698 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 3 Jul 2022 19:36:58 -0400 Subject: [PATCH 08/12] Docs --- docs/config.md | 2 +- server/server.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 28a9ee35..6908c469 100644 --- a/docs/config.md +++ b/docs/config.md @@ -875,7 +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* | 0777 | File mode of the Unix socket | +| `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/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. # From dd6af3b8f282d0157f4a57620ba832430b853b99 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 4 Jul 2022 14:33:24 -0400 Subject: [PATCH 09/12] Changelog --- docs/releases.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 1644e26e..db791e06 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -9,8 +9,10 @@ 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:** From d8ce68b2cb828b74410f826ac50ec8b331507d22 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 4 Jul 2022 14:36:37 -0400 Subject: [PATCH 10/12] Switched Pop and Pop Swoosh sounds, closes #352 --- docs/releases.md | 1 + web/src/sounds/pop-swoosh.mp3 | Bin 9806 -> 9593 bytes web/src/sounds/pop.mp3 | Bin 9593 -> 9806 bytes 3 files changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index db791e06..98a07c7e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -19,6 +19,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * 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:** diff --git a/web/src/sounds/pop-swoosh.mp3 b/web/src/sounds/pop-swoosh.mp3 index 007ce4bd4c4aa69a6bd0badf3bb6d1b76660735e..3d5bf4761d2b22186f4b5e1180d1d5a76ee381ec 100644 GIT binary patch literal 9593 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 ZJ@n(#um9`+IS7$~-vsLf{y&_A{{TYm3LyXh literal 9806 zcmdscc{o*Ty#Lz9P1_jSB*R7_$(SJ-w;}U9rOZPmvqY!%HVz_gxpUk`#b40FbEw0Dwa9 zP*SpJU^GoFEiIw{k3oMXo-Td?_CC&luKi6HX<1R3bE4v6Vut`02w-Yr0_b|WUUlR# z^zr186BQSgJsiCYjb1hN_b|~jR0Vj%4(Z@VLR{?NMnyvH?;#kWq9!Hv_XE<6>^W75 z|NI2T{coNe%Hebgw+9>FMX^7Z`ZwPDn^pR8(Bt{rjn@saaXMxkW`46&1C$_4Uoo z?d|Q|-TnO^K71G*otm1PTU}lK@nd%v1fjVMbyf7#C1J-=)c=-GnuniESr0%=!g9=M zO^g2?`2Y3_?&M$r21s8V9srP#7uEm(LH3E92i7?D#WV##m<0bt?;AgU`TmC9O+*Ys zfF6JUXUK;*q1f$OAbUrCwf2Q(*q6sKNIw9@Z~#~+gReh|l1fqx!-U!+;;-8KD2w3% zn;0CyT$Ep8AlXh&ZriHnE2012XJM)23e*Ub)%^xJpmzlAJK6$H{ zrHI>(&KhAqQ6_NBG`Gr3zEBt4%8n)j zXc)ll!Iy!LlrG>hl^&76i!Pd|L`H@&a6Wg=`$W1gaoKSwwQf8U;{~I^%(eXfOhV`m ztT71g-+j(R(P@(8DjuRAon2s8(Jq(Q#QT~`khz%AtV1lH9NeE3?U`^5$C2RB&r;)Q zOnoI+K>xe+pc0G#;Tl?%=MiW->gus_yNTg;V?gzTZgrD{VMk>fx0WFQMVnETeeas> z@7M3h46B}TvhHiJdF&%u;vS>QZnF@}Gr24J8vt%@6ta)7Ut223d@R*&BHho-m)FoM zE`&U={wDpsXGjzMU>tb*%?PHUiFc3lCwBS)Ny+S*gjCq)G zsKS0lKv~V=uvyh$lkWD&R&EK-nl!TUWAj5ncKDFL`I5)e__GbD&W`W*S*$1+b-Ub7 zx-4BQt*kS}19pPv37P=Q5pQ( zxWfl~7YP0Xbl09nC=9H0FJO{6dchrK>~8!wufUE3giMypcEAOphLhTD!EW*Sru+o$XTI2}8jt3wUQpEt#YPF{CBst5b@HUpBVj>7;nx2H? zvF~`YZvY|WWGyDAjg1&*Y{TEC;`^;a$uSPx508C9Y2&@b2DyN?e*PcsA7PYDF!tlCb_1a}OvoAgr#0UC?| ziw}<{vIQW^#uk%vDgyxH@CAn`fbiFzMg8Ux+>>K#pK-X>EBzMiCrC$@9e0CGx1910o8IO`y8OfCy1hcbu-j?s}h3_-vRkPg%*t zx^QAGCKCLbSI`VD9B2U9{RVtzmYq>!?UMHZ0H}`iek^OCg4F<+fkeFEIckde;GbwF zM2i?}&q>OH9tCFI(`|OIgK3O6T2N$kG~w6|FZo#4V3t-Xn${MSZH`oy8W*|aBJY!k-g$kwzsZp zZrX0an2lpvALHPcVH#Vhryb77JZ3kbs`BOI@#xf}Kw3Pc8=gVU{F1gA+%?|dDf!&{ z?*=FW@Y&`kEfh?ZRLtyaI^-eBW-1>#vxvLHg&!W#sHXa{ zhLrO69ZUoL*4`n;BZTpcPy~7(ud9M&e`EmeH^bxmy;&p30u%X{#@$bo!yM5oOSkwQ zFso9q;rn^Vq7cl6&JUgru;LYkm~uig@#;zb{91P}eK_JnUXaUxgBg%$FZGOnjFb8T zWtba}C?094ixm~+N;IE190s@V&esi<46t)+j(6X4J!SUp(J&5N++*$ZmGe<6My zorpD@;x)_(3qGe%KJ-Y+C4MA-=C}gu&7(rBlkE2A^q8$A{uIEI)dmU7BS}>A;HooUXC)9R;5FTN~rm_ zNFcaVWA-bxYGc9Q%zAF3&N=`ZxtnAa_cXZ%-6`m-(U|##1)-lLl`@~CP<~7>&KbrT>LM zsQc?(m#Ephs(msQ-9zttJMX{4{!4mrb75=%e{t~5@V_6*gQN4!_9S; zA(j3$y7Mxti}>1ri#oIe_s`?K3jts6Ma8JM@|yjLkEU~*=52x7?QEovZIBv1Pv3_# zM~DRt0OX$#+;9omxZnUVp7@FWhXYjSM^k!KsVS{XhG%))WtD64P!#vb(g|Qi)XOr-I9+pXV zsN4YLb_g!$0+CV+R2IS2n)9U=PFZ6mnvct_<&wXXnp9S2=br%IHRG5@6$L-fXyZd#NkX z?Q2WyG`Pcq1qo{lz-x#hv?XPSFXUelobc5X@7MxenrNZ7njJS*c|gT{#u(PCL+l4_XvCq8l_>s742d7-NOz;ZN*fi zjlhJ#&v(SIKL~3xfMyhAJ9hfN`T??+YD{As0AN9EdEuapI6k#wwe7ZkW%y$8mqy(W zx6%4-!XQ}q$Mdm~OG86u3&q6QOAU(7d|E8`6O2KyYU|^tg*QW|%r&xOLI#)3^H^qu zLX&O5${$;ISKc_^cRDR(qp)Pu%48LsoUnvChPN~Tw=_VVKNc)*=jBzq`&_B)l#rAj zp_SH#W&6%Bnc=Sq0Ich5@dvUUdOJ}*9}Cc)N1SfFJ@n|>Dy#~4NB(P6(X30Ofa(3- zFK+5iL5wqjf2p#z#)xrLQYdb0ytkPk;Xu$}b6|03WgT-=fZec^GsQkrvP6|VBe@FZY1FaT-{3SWvTaw+J z8u{P_rqu41*K-LWIWyVkJSnwDvbr(@m`YydP=mvJBQ7__T*^}PF#0y7UPTIgjqGDA zZBa3=xj}HVJf`%mc%`%Jkrn@B7dS#T2y0D&8hFSC?0s6Wf5^f|pa`)b>NLWc_0y58 z3@up}zTzB$1M4&Pw@iEQ8Fw?57XQKCb=8XK8xf&6-42OQBB&35dyxVxVvgFfT{VvB zPi!vFhD;%1pdvIau(_dh#r6OPZl=%PMm~-Rd&dCOwV_o%T>t)$ghxOJ`e0Op=h zlp|-(f)m3zC2(}_#Kp1FRN2BKm$RgE2RRZA1un87C~)91xU@JX?Ch7&n6HF&}Y_kOv7I_ zItg2&ubrmgmCvbyJt-0A1+>f$$R~u?{syo909S7xcTrt6y&UzhG)qLaHvA+)3ED+m zS37-GM_|Cip(nA+-=TW2KujVa9|YHJ#aycnU*6(&e38%(`}7yX+W4(bD6`f$;)&2k zcE(?iDZ?wln^3&(l>=SGa@?KUnLo1+o zopfjByiVVQ{#Z`BHqPVZ6i#+P3kXi~p45tup>R>#$B|~;O-P;kgWzNv(<#gNU~YWlQIh& zZ8ToAwsIqY`4s&~T{}4vzB!TF$%jGU}PD58u*jjSGxP zAH&3vLYC!PYQcgHP|(jw_B3z)hOU4kH0&-_K0it$!Xj4l*xO&9GA|FNNbhPAO<8j#)Ad8=ff0F*DW^;2xnSt8NBi4TCX5$a$tEp};zW<2-28D5r~UTVX6 zK|gwhF8QHsF}#@DZJ2-|Fa-{%aCwhtNFG1Z{m$5K)l&I;t}cZ*{wPM*;|z+-Vw?$?nyyI-Qqz zVk!9JI%ipYK_(q52>#u&7kA&t>%X-=u6{;Ot=diY^ZSBw7QfFSGp9a1yAn2V6P9HE z(Z`Kdvp>5(aXhZe)jzE&J;I_TpQWiFtPY>Q{QBN=ygs{G^S99Xw%Qs%@ibUsk!1>R{|LoqD{S#%SE`Gx86&jH0&Q&XRFcsGM1N{bLHzH=vjEKqXau3 zqu8u2^*NgwbGS5KG<5aD7`9tcOXiYSKrF=3+73BF#Go(?b&M?Qan0gN)Opc)M=uaQa=1;QTr$ zJ16K(MoGAF{3^Kl8#uf_(YWknoE7cRxa5%TZz^PH((S4paU&c$da7}|%>1?C6vT~0 z?_!o#Z(qNvb?;1^&x3_Vb_NGJb`-iljLVBTJm~D${sC%FdVI;cda%b@LUJUrw@|R# z8_vdk!jw&*dK9D&J3$t%6j=KXC>xE4N#|iN-0qDnl;*Ili(^zuDPA(rhy-B)m#^;TC4RHX8Y(vyX5>(mlW{~Q#as49_}_i4mkx@+jxHWhC#AhVKiNmB%In8Of5P1iJ2C{F+!+z?E5Ijr=cO3E+nJB~ znG5ptfneU%Wh;GRlH8K13G2|v2X&zoefe2GN+vb~=qT_Ln0`1Dxc)Y|pRS9C6%{9$ z(%tXop74kT6`%V`LnU;Qr`uR{(ep-Co~P}e5DC9+q~thL&wsk0JD}^d-f0$Q#ua;K z9#UL0Kyg4ini4##1=%_~G!d@K0mOU-hju6%?>pkZMdF1a|NST`Q}%4P1ye~J_xR#n z+#ds!XihckzKrmVEt7NoqG`f9mc#n9P5(IapjOufUUp8n_fv&%{_cDKGc`Km91A%3f?fakM> zntFb0PP-PNlC25^-%X!U9AR5~g~ZU_6zqdb*Y55>?7r=l!+4zdzU=Gbbd0HXILc`)xzj{yjNJ%n z%xTGE`2-5H#986=Y#ERw7qpn{KDFSu=Ayl#Fq0nOU365t0~N?ojd?z2uEBnq@QML(-Zm%KF@s6)U(H&9FOW(LBFD0pr21 zkLHtB`XqYGdlv0=p|!Fh_9v95LF!R}h#x0|Wee1qD1%A4fUa!75F!7RX6wlQw` zR>da_8Tm@oj-#0KRJU^-pryqeU*!GA2O5C;R*Pdt=}j*lZ(+K7O1vZ?pD-x8RdlU4 zP>{=9yq$@Y8~pX{9D(8ij(Wv^0-72Gzu-(VI}}QeHmK`ctBboU_G;;Sa&kyaZIZ+K z?8kq5bl{&etI#R{@=rK)icNt--%^-{JFOB3x;3x&)ouCt`Bw1Be4VJ-L z9E^ZT`|_~sJ5}uxs*xfWEDNTQ73fFp6-Slf^Healp3d-m2cQwklCJv|qR2H3cM>|KyB zIi=A+eB-Ka3nO=MI~8&a_QI|jBjM?EC5fnz0KpYhjv8@s;96X%;`S&E0+pP=M6IjE zj@3rdygg19^|A5x1Ho+55%!;sqPfBEYm&A-R0mlLfoIeNkr*D2Xt5q9Y4@HtzU&Lf zN2DqXRM^Qbkh(L&l*TP6-dJvO#uJ}FwMxBdngyVkKv$fH^dU?s?ODK6!ZeifYaT#eP`2_@!2~K?$4J!NwCN9fdwV{KJ|rijP_<)y_^yZM`i0{ z+T5bp9F8PN<>gcGnfbngPBpuf_>a@EOLj>Tl|{**l!J*sWmOhM`O{#QI9G%n3tCV6I()_v3*45CSw(n@jAW_>Np>8zBO6hL>b$&Fn%ww z%;}f9lz(h=N2swG$|#PqNoMR5Ej5o*wY^g$xaXoDI`*dfDS_1H_U_Kb-x4t?V?3w3 z<<7TPPpdLaYrM*LLf!3ssN3fT%(u}-sjHf2g~AXZ^I3hFZB)*r)oM;Jp3!E~Ae+f^C9J=w2T|utzSKL#&5@K)tI*R*{?u zifTqBIq4t*rlqPMKI~^xIbPvA2kWy!KNUn;-L{_?Ym{U z>>u>zdAk?WHVv2QpSC6-`_jpv!-Rb&&(Caeg z#;MMs)^x7UGhvjpLa#HS;#F0v*s5;uz*FYs1P+4lY!_o@7M;_X#s=qeFg!CvEJGQk z16F~N?qdp+n&3X}!n3@9s?u2q3!P5tg+1h~6lOl}_g6}rUyriIlQe(jQ3~eJ;3MvU ztP;5TmR;FJQ(8`GR_BXxB>cwquE342ZT%G9VkiEror->eTBtKLxrjZI+y)x~Dk6Bu z2nd-Lv0g3s+posnJnU3pvz_gxpUk`#b40FbEw0Dwa9 zP*SpJU^GoFEiIw{k3oMXo-Td?_CC&luKi6HX<1R3bE4v6Vut`02w-Yr0_b|WUUlR# z^zr186BQSgJsiCYjb1hN_b|~jR0Vj%4(Z@VLR{?NMnyvH?;#kWq9!Hv_XE<6>^W75 z|NI2T{coNe%Hebgw+9>FMX^7Z`ZwPDn^pR8(Bt{rjn@saaXMxkW`46&1C$_4Uoo z?d|Q|-TnO^K71G*otm1PTU}lK@nd%v1fjVMbyf7#C1J-=)c=-GnuniESr0%=!g9=M zO^g2?`2Y3_?&M$r21s8V9srP#7uEm(LH3E92i7?D#WV##m<0bt?;AgU`TmC9O+*Ys zfF6JUXUK;*q1f$OAbUrCwf2Q(*q6sKNIw9@Z~#~+gReh|l1fqx!-U!+;;-8KD2w3% zn;0CyT$Ep8AlXh&ZriHnE2012XJM)23e*Ub)%^xJpmzlAJK6$H{ zrHI>(&KhAqQ6_NBG`Gr3zEBt4%8n)j zXc)ll!Iy!LlrG>hl^&76i!Pd|L`H@&a6Wg=`$W1gaoKSwwQf8U;{~I^%(eXfOhV`m ztT71g-+j(R(P@(8DjuRAon2s8(Jq(Q#QT~`khz%AtV1lH9NeE3?U`^5$C2RB&r;)Q zOnoI+K>xe+pc0G#;Tl?%=MiW->gus_yNTg;V?gzTZgrD{VMk>fx0WFQMVnETeeas> z@7M3h46B}TvhHiJdF&%u;vS>QZnF@}Gr24J8vt%@6ta)7Ut223d@R*&BHho-m)FoM zE`&U={wDpsXGjzMU>tb*%?PHUiFc3lCwBS)Ny+S*gjCq)G zsKS0lKv~V=uvyh$lkWD&R&EK-nl!TUWAj5ncKDFL`I5)e__GbD&W`W*S*$1+b-Ub7 zx-4BQt*kS}19pPv37P=Q5pQ( zxWfl~7YP0Xbl09nC=9H0FJO{6dchrK>~8!wufUE3giMypcEAOphLhTD!EW*Sru+o$XTI2}8jt3wUQpEt#YPF{CBst5b@HUpBVj>7;nx2H? zvF~`YZvY|WWGyDAjg1&*Y{TEC;`^;a$uSPx508C9Y2&@b2DyN?e*PcsA7PYDF!tlCb_1a}OvoAgr#0UC?| ziw}<{vIQW^#uk%vDgyxH@CAn`fbiFzMg8Ux+>>K#pK-X>EBzMiCrC$@9e0CGx1910o8IO`y8OfCy1hcbu-j?s}h3_-vRkPg%*t zx^QAGCKCLbSI`VD9B2U9{RVtzmYq>!?UMHZ0H}`iek^OCg4F<+fkeFEIckde;GbwF zM2i?}&q>OH9tCFI(`|OIgK3O6T2N$kG~w6|FZo#4V3t-Xn${MSZH`oy8W*|aBJY!k-g$kwzsZp zZrX0an2lpvALHPcVH#Vhryb77JZ3kbs`BOI@#xf}Kw3Pc8=gVU{F1gA+%?|dDf!&{ z?*=FW@Y&`kEfh?ZRLtyaI^-eBW-1>#vxvLHg&!W#sHXa{ zhLrO69ZUoL*4`n;BZTpcPy~7(ud9M&e`EmeH^bxmy;&p30u%X{#@$bo!yM5oOSkwQ zFso9q;rn^Vq7cl6&JUgru;LYkm~uig@#;zb{91P}eK_JnUXaUxgBg%$FZGOnjFb8T zWtba}C?094ixm~+N;IE190s@V&esi<46t)+j(6X4J!SUp(J&5N++*$ZmGe<6My zorpD@;x)_(3qGe%KJ-Y+C4MA-=C}gu&7(rBlkE2A^q8$A{uIEI)dmU7BS}>A;HooUXC)9R;5FTN~rm_ zNFcaVWA-bxYGc9Q%zAF3&N=`ZxtnAa_cXZ%-6`m-(U|##1)-lLl`@~CP<~7>&KbrT>LM zsQc?(m#Ephs(msQ-9zttJMX{4{!4mrb75=%e{t~5@V_6*gQN4!_9S; zA(j3$y7Mxti}>1ri#oIe_s`?K3jts6Ma8JM@|yjLkEU~*=52x7?QEovZIBv1Pv3_# zM~DRt0OX$#+;9omxZnUVp7@FWhXYjSM^k!KsVS{XhG%))WtD64P!#vb(g|Qi)XOr-I9+pXV zsN4YLb_g!$0+CV+R2IS2n)9U=PFZ6mnvct_<&wXXnp9S2=br%IHRG5@6$L-fXyZd#NkX z?Q2WyG`Pcq1qo{lz-x#hv?XPSFXUelobc5X@7MxenrNZ7njJS*c|gT{#u(PCL+l4_XvCq8l_>s742d7-NOz;ZN*fi zjlhJ#&v(SIKL~3xfMyhAJ9hfN`T??+YD{As0AN9EdEuapI6k#wwe7ZkW%y$8mqy(W zx6%4-!XQ}q$Mdm~OG86u3&q6QOAU(7d|E8`6O2KyYU|^tg*QW|%r&xOLI#)3^H^qu zLX&O5${$;ISKc_^cRDR(qp)Pu%48LsoUnvChPN~Tw=_VVKNc)*=jBzq`&_B)l#rAj zp_SH#W&6%Bnc=Sq0Ich5@dvUUdOJ}*9}Cc)N1SfFJ@n|>Dy#~4NB(P6(X30Ofa(3- zFK+5iL5wqjf2p#z#)xrLQYdb0ytkPk;Xu$}b6|03WgT-=fZec^GsQkrvP6|VBe@FZY1FaT-{3SWvTaw+J z8u{P_rqu41*K-LWIWyVkJSnwDvbr(@m`YydP=mvJBQ7__T*^}PF#0y7UPTIgjqGDA zZBa3=xj}HVJf`%mc%`%Jkrn@B7dS#T2y0D&8hFSC?0s6Wf5^f|pa`)b>NLWc_0y58 z3@up}zTzB$1M4&Pw@iEQ8Fw?57XQKCb=8XK8xf&6-42OQBB&35dyxVxVvgFfT{VvB zPi!vFhD;%1pdvIau(_dh#r6OPZl=%PMm~-Rd&dCOwV_o%T>t)$ghxOJ`e0Op=h zlp|-(f)m3zC2(}_#Kp1FRN2BKm$RgE2RRZA1un87C~)91xU@JX?Ch7&n6HF&}Y_kOv7I_ zItg2&ubrmgmCvbyJt-0A1+>f$$R~u?{syo909S7xcTrt6y&UzhG)qLaHvA+)3ED+m zS37-GM_|Cip(nA+-=TW2KujVa9|YHJ#aycnU*6(&e38%(`}7yX+W4(bD6`f$;)&2k zcE(?iDZ?wln^3&(l>=SGa@?KUnLo1+o zopfjByiVVQ{#Z`BHqPVZ6i#+P3kXi~p45tup>R>#$B|~;O-P;kgWzNv(<#gNU~YWlQIh& zZ8ToAwsIqY`4s&~T{}4vzB!TF$%jGU}PD58u*jjSGxP zAH&3vLYC!PYQcgHP|(jw_B3z)hOU4kH0&-_K0it$!Xj4l*xO&9GA|FNNbhPAO<8j#)Ad8=ff0F*DW^;2xnSt8NBi4TCX5$a$tEp};zW<2-28D5r~UTVX6 zK|gwhF8QHsF}#@DZJ2-|Fa-{%aCwhtNFG1Z{m$5K)l&I;t}cZ*{wPM*;|z+-Vw?$?nyyI-Qqz zVk!9JI%ipYK_(q52>#u&7kA&t>%X-=u6{;Ot=diY^ZSBw7QfFSGp9a1yAn2V6P9HE z(Z`Kdvp>5(aXhZe)jzE&J;I_TpQWiFtPY>Q{QBN=ygs{G^S99Xw%Qs%@ibUsk!1>R{|LoqD{S#%SE`Gx86&jH0&Q&XRFcsGM1N{bLHzH=vjEKqXau3 zqu8u2^*NgwbGS5KG<5aD7`9tcOXiYSKrF=3+73BF#Go(?b&M?Qan0gN)Opc)M=uaQa=1;QTr$ zJ16K(MoGAF{3^Kl8#uf_(YWknoE7cRxa5%TZz^PH((S4paU&c$da7}|%>1?C6vT~0 z?_!o#Z(qNvb?;1^&x3_Vb_NGJb`-iljLVBTJm~D${sC%FdVI;cda%b@LUJUrw@|R# z8_vdk!jw&*dK9D&J3$t%6j=KXC>xE4N#|iN-0qDnl;*Ili(^zuDPA(rhy-B)m#^;TC4RHX8Y(vyX5>(mlW{~Q#as49_}_i4mkx@+jxHWhC#AhVKiNmB%In8Of5P1iJ2C{F+!+z?E5Ijr=cO3E+nJB~ znG5ptfneU%Wh;GRlH8K13G2|v2X&zoefe2GN+vb~=qT_Ln0`1Dxc)Y|pRS9C6%{9$ z(%tXop74kT6`%V`LnU;Qr`uR{(ep-Co~P}e5DC9+q~thL&wsk0JD}^d-f0$Q#ua;K z9#UL0Kyg4ini4##1=%_~G!d@K0mOU-hju6%?>pkZMdF1a|NST`Q}%4P1ye~J_xR#n z+#ds!XihckzKrmVEt7NoqG`f9mc#n9P5(IapjOufUUp8n_fv&%{_cDKGc`Km91A%3f?fakM> zntFb0PP-PNlC25^-%X!U9AR5~g~ZU_6zqdb*Y55>?7r=l!+4zdzU=Gbbd0HXILc`)xzj{yjNJ%n z%xTGE`2-5H#986=Y#ERw7qpn{KDFSu=Ayl#Fq0nOU365t0~N?ojd?z2uEBnq@QML(-Zm%KF@s6)U(H&9FOW(LBFD0pr21 zkLHtB`XqYGdlv0=p|!Fh_9v95LF!R}h#x0|Wee1qD1%A4fUa!75F!7RX6wlQw` zR>da_8Tm@oj-#0KRJU^-pryqeU*!GA2O5C;R*Pdt=}j*lZ(+K7O1vZ?pD-x8RdlU4 zP>{=9yq$@Y8~pX{9D(8ij(Wv^0-72Gzu-(VI}}QeHmK`ctBboU_G;;Sa&kyaZIZ+K z?8kq5bl{&etI#R{@=rK)icNt--%^-{JFOB3x;3x&)ouCt`Bw1Be4VJ-L z9E^ZT`|_~sJ5}uxs*xfWEDNTQ73fFp6-Slf^Healp3d-m2cQwklCJv|qR2H3cM>|KyB zIi=A+eB-Ka3nO=MI~8&a_QI|jBjM?EC5fnz0KpYhjv8@s;96X%;`S&E0+pP=M6IjE zj@3rdygg19^|A5x1Ho+55%!;sqPfBEYm&A-R0mlLfoIeNkr*D2Xt5q9Y4@HtzU&Lf zN2DqXRM^Qbkh(L&l*TP6-dJvO#uJ}FwMxBdngyVkKv$fH^dU?s?ODK6!ZeifYaT#eP`2_@!2~K?$4J!NwCN9fdwV{KJ|rijP_<)y_^yZM`i0{ z+T5bp9F8PN<>gcGnfbngPBpuf_>a@EOLj>Tl|{**l!J*sWmOhM`O{#QI9G%n3tCV6I()_v3*45CSw(n@jAW_>Np>8zBO6hL>b$&Fn%ww z%;}f9lz(h=N2swG$|#PqNoMR5Ej5o*wY^g$xaXoDI`*dfDS_1H_U_Kb-x4t?V?3w3 z<<7TPPpdLaYrM*LLf!3ssN3fT%(u}-sjHf2g~AXZ^I3hFZB)*r)oM;Jp3!E~Ae+f^C9J=w2T|utzSKL#&5@K)tI*R*{?u zifTqBIq4t*rlqPMKI~^xIbPvA2kWy!KNUn;-L{_?Ym{U z>>u>zdAk?WHVv2QpSC6-`_jpv!-Rb&&(Caeg z#;MMs)^x7UGhvjpLa#HS;#F0v*s5;uz*FYs1P+4lY!_o@7M;_X#s=qeFg!CvEJGQk z16F~N?qdp+n&3X}!n3@9s?u2q3!P5tg+1h~6lOl}_g6}rUyriIlQe(jQ3~eJ;3MvU ztP;5TmR;FJQ(8`GR_BXxB>cwquE342ZT%G9VkiEror->eTBtKLxrjZI+y)x~Dk6Bu z2nd-Lv0g3s+posnJnU3pv9DkvqPNUu^QfIvXO(2FR&6Oj&riqd-x zT?8r8u}}oWoZ$EKtnc1))?N3H`_H-0%B*>3_I}?f&z?OqdqoE-NeWN_07zH?000dU zkc`R=;Ux=Kzimh;QxKB6osXZVrw81^!h%p@AS6E*cjP@Eq$AP?>EVC`fd3I$UMPEd z1^Ku*Ir{e+{`0Dvi@lGXPmmbgQUgY4ftrvmx-ANZh(aXbP?-9E zt^C()Tp^mXW@y zfrjM8Xh~cC_D>oJB33Z~5G`Hw!@(=q>#yMd+Z=zE2L;f51pvZPe-_NeBN1`ZGA96F z-s_2SXeW#uk3YiS7cV?)mEK#^`7YUDWjDqkeld%G;qM#Su9-?r$E2Yrw66Lg{%`$7 z?pH3LG@#Sw`)L2x<%rvAmA9Oo{#{&BY${dqVXRNl4{S?3eht6*FQHA?h}<0d*j&rh zLHNlY(sJ0eh^My)0$kM+954Kx>yi8S8xwwJ*pFdr@VZjWPQWE6uJj_9WMXs0g;@Ml z`Cn>$XOJN4ra^KuR(pn|oM=vpNEwem#{Zst!hCOp@Z$Ny zft=<$#B4L@SUw^WrO*b8xH%Bc|P;NT8M=&Q#EX1I>>yuYOl+Hc9JAi22@}5^OTTb16mV}ig3h~I+oj!lUrh#~Mx2${p{SZ@0LVm~#uiXCmgMN>o z%vryWtmXrIrmLXMQS-zqguXwB7%2-Qmt^qe_93}Y zz~hse)h{#9)LlG2+#8Lzu-DO`<*~246~USH!9Oz&e`W-od%AQ8(7G^oj&=%yB>+gX zzOWITL~lat3KT%4kQEyf{x+?ECMHU1Sn;_&b9Yg$Y)~azjxcVxW0rKuU*~@xzVb$r^Ocdp@9{@bS71;90GeIZ?8Kjj z4WP*j;w-$}p%`GMV2^m5n>c^T1-+N(@spG#Lu>py>~n2n5G4k3+Vt)oOEgIB$SDm#C2GIH(+nYGS#HIG4xTR zv`s~cG`zoiE0w616ae6~HKg$Lp-)#naJ(S;0_XGNwd05aOEbKNZdkzdzbeD68X8LA zPM$a6x+djYiQF+8G*l>CK=!l%KpTKknejW+H_HIG_wwrp_OZ3^fb?y9!Q)HCPne-1 zy-P&;SJei`90wB$HH><8Tm~IJ;Lk=8XDI6NAkFg-TMA`5irst)fY&&mc*`zE^~$9$ z55N!#iVuoHH1zMkGIK2fgk@z+Y%DKdhLB2LVuUahYOzcBn25_sGV+m9^8%!%Y`vL@ zo%xnKsPhjbAS3`vhRB9vyT<@ZxHcmN@fE)(#roqGQ|zjb-4ZK4*aER&5h^lbeXsX7 zKgMKLQFcVwP6z9E9&`wxddjY2KOT$Hrs^T3{4x!2ncyw8ves4n|jfc2Ns8 zX$kF#s`#pCy%|zD7OTOrQX)WFM3UU$%PoQaB9FleNwwAX#3!1N?-iQ)hBK2~?=&qL_Vi||I}+(hrf<+K8Rd(VaK~g1?i5`?XS?|9faT^k@?x)s*azQEwpkeds}Po z{;6q-`Eq|d*Eh9B5JXs)#PoE!WVj_+0DwOwIL1TQ{xr#!*UD>oX#W{>4K5{4wKVYF zJvsK)!yELkd19{PUi@y-ee&l^n?J7M@teM*X;2e@S@fOobj>ann`MomrNPp6Atrkf z<7wmWSdSZG#$EUSZoZ2b9$)5+vtn%%W8t)zn!0W@BjbAMFRa&0V{y5s?z0InH zk>EPDvukW!@YFq@61n?4rcSIb6Y{wsDrds%H+8E)*mA9okc%hrWS&; z{io$~D$OOg`LDL=l^;HmD~RPVe4Zm5>g@K-?;+gM+BR{>cxAn!e%i%(Rw?f!srS7d zxa`$(x4E|TWX+oom9w9f8cce8?#VMbi}+`U&ZE_0l&aFOFB9h8D9-PW2$}c_e(d~I zdaQWfCHi1~+ySByOBGpYem)ozacTJ0lQdffvJ9lIO#;Xy=LWJ}^wIS=kU}5aK)U{3 zHUeP=%6IhE!lt#t(v!3T90^AyL@&S}VLyyVE>W@Cwp1^ph?pcE%k4M$HJxIu`PGvm+fuv?OmX1dX`x23 zk{X}>b{DUGdXSje3$c57&D?aR>#qH(6!wZ3AmQ&y&r*OY{|K`82GDTTG(=={;Ry9E*%5BR4+w8 zw}4zQtMFG!f>?jXY|E|TyOsz_`vBir5}0w;{4WQ&l?i|6Wmk;JAiZ6uzM-K@^ZXNV zS=Y@G#ildH<09xH{P0{GlrtcCAnQo+!m+W}k~^Hfx9GVBYe9zjWHK+CzQfGLw*D9A z*~+E)2os$qX{#0y62Jm(OK~zDkZGm8dM9o0UM(TxXly3P{DJ_Sb@1oOtTzo zyz=0Jph=WJzwbZ@CD%AhJ0+ZORAR;si4L*_Xc0i@19!0oO4J`!#)Rvos6FH`7=D*F|51+*MbBXL&j3j*}b(lmHAvB zrn$OMHuIsi`RwV164(MaXIWarb6{~txoy#J{qxl!Gnm1WPolIKGrywMuX-3=6#yXc zyUM?x$fnh7C5x~bOU@1B0kZuh zcuM9sSrR+NjJ?*V3%^O;nImaWD;vKTn2lbAUuGmt)2Ek-D5j?2v1usLjgaIv3D;>$ z!4$YydK^tz)D}@4ExkuL_bHBJtCb#^?^!zRlr?V|45L(Kt(JJ9c?EZ4?V=T)F>&C9 z*%6hYmG+y=H!_l7*JFJ|Mob&5oYqzydvFso&l5R?lCnT5SUT=MV>~$8IG1V-@sR8trA_V!rsxK2JCz5L5~Mwjl-YT6GGcR$Om$Qcfr$4$O`=_oYTZ58EZ zwHsp^mlcqX3}Et-QF~V=7{x@6A_sta-|psbTo5#Y^4EEw_d|&DE7x#yJ1Oy7cukb_ z4%oLnN`a4=p6bNg87>!D!OACE|6RJw{byDfZ@8+MKTjiCZ7(88&2cDSv!D_j2rVd@ zXe;ZTuo!^9&d}yby!-4}2APb)M!1w1#s~o|U+X{>n7@EDNcq0Z-1D@HUs=Bfp6hQ^ zy(0QL^}#}FbFqNec|V86){bbT>1(zQ)0OY}_;iyW3OgFq3vyY5F|SL4(vp6;RgY<) z35P05zVko49ac|G``O>P?poKnp{~F}$0Mvx#j_Cjqs>fzK)65%N-W4vrZ^;lLj(Dl zZ}RoK@#ruZuhn%Xmt56pu1oLPtRAqN|_2lEJ;7>GtXuoc#QKtWM8?H+fq=b0D^JP!A;-P%tl23ujgwGzYc$w_992Z ztv>pDd0_|x$uaizWQ*h-i(&mQbRmj~&HJs_Um<>Z#kt?XD{u0OXW&oqhxk*05q#yJ zq2QsPlvoUM_LVqKk}q*fsmCWKOK1%dZi9#cfWNNe5W&Uy@a+rEToA-iD7^#n?fvzJ zSB0MIr+VTw0qeWEUZ}wFDAn;V5?U;8_}KYIt zvWzUrJ;^UYH`s4HoGN$0zBl=K_oVdzt5HA6W7=JmLWZHV;-wd}=Ua7=iJvl#l#Wl5 z`7n{=Qs$jSKmvdlWc#hHnM4)TvgV6PH{7th;zA9(2L6FZPgk*p%wYKTU_4C~^$ zRT^ff!i2!rPdm`d5G!-+V3g3ks*9!i1$C&1H26WV1lfIA3@KS6oXbRMho7Mx)wTkl zXZ^WeiVZFEU76DyDu}^U-=6YLXNJ7KK9$w!IPg3WSK2kcYkKb}?akWU$I0!?IxUw% zZ@h2A+oAa89&@g@G9ThoKFK5Zy~v(InWQAs0u-!G?LL2x3k z2QNezB|ANJyPFwEvZV@m=<7ybe?B}DygEO7{%R1H!N!1yE1>~KXpvJ$=S96>$fOX| z0Y}=UBAn>PS8X)azZTWAXQtz1Ao57-7ZOhS*+_w7%OT^C8y0#bBG9qo(*f@L{5(XT zDZX>cuyKk7*N-I~k2m)!BGo_nsXL%d;NjJz{tA@@O_{6oisG z`2z<98P(yvvRQ5uDRVGCK*F3hQk9aYPrshwMIt2`z#S$D`-ru;*YY_S8*t0I&VRf` z+RrKb#paB~(lU%Jo^{JTU86rzgmB}+AaH=g+m=QlG+cp%@YzM+hB?1jb*F|7MF`!s z6E5!?S+KH)NAe*!WXT!l__RY~V5n`q-3f`vby^8E(<^YnNkMjQ^3;7-q&Zc5YEQrq zoj33K&X?S4VPp=RE0?jaRw%+Vnw~#Q&3(6eXKw^Qm*L4!FV>s@%uR^Vbd*c5x;QI`9zHq4nr;{m z;`!+f#<`?y|CRgHRx=S^n`Y4Mu$ZuEhfvLH?EQV<6}_ktsVzQwmF~V+uZYeRE16Ia z&)>>UpYC$p2@iXKHw+lc-GA$52OFAK^7%$#a>;#edq{YHaK8y|HD*9sEIN9Cq5mLH z!@y)waQ$_eCV3h2AY%xvm%*q>b5#^=En2MzjmnLt5z9Ccz*ZSEx^&U;Q&TcsFB*AM zqkuI~gptw|7*49W1gvT=iVHSSojK<{yD^+=QN-Q3#(o=OHZFKq}`bpeXWi&XKK2)puG+e*IprdIweF%1x9GTN26K;v8DD^mF9OY~CBa+7_$ z44komU@Hg3ejnw>Rp47QsH6;`qOwPzqb~w^6*C5;AKfuP?m}l#$0WvJEe2Dpe(H-? zbKUlM=Qq{MPi(Fn!XkH!WpY!+GD<){0$$tU4!w1cj$cknnv_zxHAt=m?x1X-m+q6C z$kpPc8KT){I)NGmCLPSc`WQmS*Dmc#DGr@k5Th`AkmzQ4T{t;7ID`HJ!M z(vwZ_jIP3V2CU%?3mSB9=*R?} z#xX2=qe-Sy`|8%kYS=~{-ZeK0q0=q+XR=wMpQ-N@dlLdZe^vvB#mOZ=l=TXT*+($D zU$35l{JlV%F@Lv^5s`jGIDY?Q5AtgfhALBpk?A%$t&z!OC)YD3Qd&_Pp6uS>$IW#d zR~I?lh(4>-K|9T2l6kz`A{^fO=$5TdDtl+@kjyra*?tY`T<^)li2m?N;2BZ`vu40V$HHMTC8>INxcjT24GqRp>T_Muq^1SuU(Pc!g0cxj_y9u-c&~Wa z<8I7}_`?U`E|3A~p6?ZLiMe|UFI(`R-F zz|cePz^i@vl?XE>4a1=!@@A=xo|t2i%+Cgr0tk4(;FTjFq9!DDljT~9euL2c0bOB= zL@8__vmBPAc_QYK8*rkmZx&t{Z(%3-RC-gy#7Bs(Mo-Oglctpq?ie|Z^0P3 znA2zojh@=yiL>cZC2YjK|5&!8SVNkr{niMga5Af}BhUK1?9!49HC#)U+lO4`7PT08 zo}m(x$R=={QpstBYq-mOclucIF|5bUT6ZR-br%0I%WED;3A`Px*%q-vOy9UvS!SF^ zRn>s`R$C<~C0a=X>t{uO>WHl2ktd+#JEtg;-)m=i=Cn=0*4r^JH13N;z!!v!m-Qa=Ny_mH+8}FE%l(#NeGUudA!7H zjZQ{g0wf!Y@M>EjF(cx+9;oa!}vxrci+}T!=tz>KG(A!b4Q==@6Bh0W5`ha zwiU#YW6$!?#6)rk9nJQ3E?G&aqgHM71n7u(eFWMHFZ}!w>d^D<$QdQn{x3n& z(aHpZ{%*wbckQSjLsaBd@-WAHcn?Rto%fAy!eo-E#s)Ym zB3GDHNZ_40a{<=1XiEbI(O|w{@>$dCWk0?yrmk0AaUG$+QnkoryLUci5^C#2S9ob3 z6nKj$v48PvWZ>2WpE&eho!9XpSE#|gDLdOcnSMDfm*V;C7{A@vrZa`q5dgil#$y$BUrTZp7I7#0I2jB=LiBAp(vEcilpZ1YJK{45YZCG01K4mGzqpY#m>{U@joI3%+g4I$U~>qeNcVXFVOsqU@yydu`- zipOBMaWM=a`9WEocHWAL4>DC0?{u?o>uK>6`>gK|ARE`;eza}m(_yxGV?RMv#l%0} zNW0guHprk-PD~cV>ntkgVI#rhcbf7Tk}-B)Z=r#(HAlnz$&; zZ9=b8vg2XTX_~|KgGyXEjM0iF)ihGZM6J$4&1imXCd5hyY1ZjFkN8M))BbxI{uI|X zxcGYJsRbc+ds`KO@-SDaldrfH3FRmAumeHjb+Z{uJtZI5ZOJcuR%0+oqojzap9tvHmy|Cl4adBQoP_^n-T3s{`-wu zhyO^@;rO7|E<@+_+ub!HU1R*Ua|ws`VDOSg;?DiF;_TOzmF3|^1X?O=zYxuQQX1Z& zN1TYuv)D662y~xDvxQQ)nWP@y9_JJsG{QXBOFN!VCwI6@UROD7ygu0+an@WUK_JbYx)cHDMuW0}L?xMYS@{eRqG%xf` zq3gBauh6lFRmKzRS2D##USczEG1kkI6M;9bS^tP-IY(w3XEnVm!+CW-dvrt07gEi) zMNAPtoia8zH1M&jD828i8qs^{PQw?G7DLTSRZS{DbN=Gm4L-x*!vK(etM#^MsV`RY zllgJ0WAK+IMx%@Z%J@qi<%_8stsCRU2b~LRjhX76*nny2LkQJsV{Ki{A#UD>GLIK6 zJ`1;=lY4gOS*qSiz*IOkVSjq!q~akyJRxjvUk>|fwT*<)!MXiI{c848Sz3^c_uj;) zV$SVj(|<0rf3j_>Q!cg97rn33ECSGRnNLRn^w(*Up-yCJS!JBeX}Wz>wo)6oL;eP6 z%4B7gzwa}#tYgP!`*8$rZ!l}F!BzFJ^jP>PzD&j9ZD0c2Rxm)`<7s4JCqu7J1|M0r zh8bwK+(LY=nx%mwMM7~4QCGd@lKf}nHM5u=BG(56%K z-_?MtBLuI;mP}Cy!kHd0g>O3l9avQQlG3v%nb`<3!Tp3lV+ zK~a>VU7F|v;t#K{;$f53<+>w=iB?$~q`{~*tX39EMUz_GOf3%C1rq6*wOe!0c|AMy zG`?!0svA<-V2rD9xj)5w@4%|*_7)63eHxB`Sv-5c%+-H%#CAN*D2xi0ccXkvcO2aK zpZ{+#{dfHLwg0a1KlK@ZW{dcRIwbPy4?^F3{SQI3Tx(qUAZr3Sf9?SPQ^)py-EaIE ZJ@n(#um9`+IS7$~-vsLf{y&_A{{TYm3LyXh From 10a9aca2a1dc93c6e09954012d84d39e8319dcd2 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 8 Jul 2022 10:00:04 -0400 Subject: [PATCH 11/12] Delete expired attachments based on mod time instead of DB entry to avoid races --- docs/releases.md | 1 + server/file_cache.go | 23 ++++++++++++++- server/file_cache_test.go | 54 ++++++++++++++++++++++++++---------- server/message_cache.go | 21 -------------- server/message_cache_test.go | 4 --- server/server.go | 5 ++-- 6 files changed, 65 insertions(+), 43 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 98a07c7e..3ae44d05 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -40,6 +40,7 @@ Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up s * `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/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 ca0d6393..94f35801 100644 --- a/server/server.go +++ b/server/server.go @@ -1116,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 { From 88a77cb1323ea7a8b85fd1b18c80ad24aaf2adca Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 8 Jul 2022 10:16:23 -0400 Subject: [PATCH 12/12] Fix race --- server/server_test.go | 2 ++ 1 file changed, 2 insertions(+) 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)