diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3bf2a12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +dist +*/node_modules +Dockerfile* diff --git a/.gitignore b/.gitignore index b0c2d33..f695607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ dist/ +dev-dist/ build/ .idea/ .vscode/ diff --git a/Dockerfile b/Dockerfile index 7c2052e..feb813f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine +FROM r.batts.cloud/debian:testing LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" LABEL org.opencontainers.image.url="https://ntfy.sh/" diff --git a/Dockerfile-build b/Dockerfile-build new file mode 100644 index 0000000..9c6d1bc --- /dev/null +++ b/Dockerfile-build @@ -0,0 +1,54 @@ +FROM r.batts.cloud/golang:1.19 as builder + +ARG VERSION=dev +ARG COMMIT=unknown + +RUN apt-get update +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash +RUN apt-get install -y \ + build-essential \ + nodejs \ + python3-pip \ + python3-venv + +WORKDIR /app +ADD Makefile . + +# docs +ADD ./requirements.txt . +RUN make docs-deps +ADD ./mkdocs.yml . +ADD ./docs ./docs +RUN make docs-build + +# web +ADD ./web/package.json ./web/package-lock.json ./web/ +RUN make web-deps +ADD ./web ./web +RUN make web-build + +# cli & server +ADD go.mod go.sum main.go ./ +ADD ./client ./client +ADD ./cmd ./cmd +ADD ./log ./log +ADD ./server ./server +ADD ./user ./user +ADD ./util ./util +RUN make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server + +FROM r.batts.cloud/debian:testing + +LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com" +LABEL org.opencontainers.image.url="https://ntfy.sh/" +LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/" +LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy" +LABEL org.opencontainers.image.vendor="Philipp C. Heckel" +LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0" +LABEL org.opencontainers.image.title="ntfy" +LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST" + +COPY --from=builder /app/dist/ntfy_linux_server/ntfy /usr/bin/ntfy + +EXPOSE 80/tcp +ENTRYPOINT ["ntfy"] diff --git a/Makefile b/Makefile index 7398844..440bfa6 100644 --- a/Makefile +++ b/Makefile @@ -31,12 +31,16 @@ help: @echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)" @echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)" @echo + @echo "Build dev Docker:" + @echo " make docker-dev - Build client & server for current architecture using Docker only" + @echo @echo "Build web app:" @echo " make web - Build the web app" @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" - @echo " make web-format - Run prettier on the web app - @echo " make web-format-check - Run prettier on the web app, but don't change anything + @echo " make web-lint - Run eslint on the web app" + @echo " make web-format - Run prettier on the web app" + @echo " make web-format-check - Run prettier on the web app, but don't change anything" @echo @echo "Build documentation:" @echo " make docs - Build the documentation" @@ -82,23 +86,33 @@ build: web docs cli update: web-deps-update cli-deps-update docs-deps-update docker pull alpine +docker-dev: + docker build \ + --file ./Dockerfile-build \ + --tag binwiederhier/ntfy:$(VERSION) \ + --tag binwiederhier/ntfy:dev \ + --build-arg VERSION=$(VERSION) \ + --build-arg COMMIT=$(COMMIT) \ + ./ + # Ubuntu-specific build-deps-ubuntu: - sudo apt update - sudo apt install -y \ + sudo apt-get update + sudo apt-get install -y \ curl \ gcc-aarch64-linux-gnu \ gcc-arm-linux-gnueabi \ jq - which pip3 || sudo apt install -y python3-pip + which pip3 || sudo apt-get install -y python3-pip # Documentation docs: docs-deps docs-build -docs-build: .PHONY - @if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ +docs-build: venv .PHONY + @. venv/bin/activate && \ + if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \ if which python3.8; then \ echo "python3.8 $(shell which mkdocs) build"; \ python3.8 $(shell which mkdocs) build; \ @@ -111,10 +125,15 @@ docs-build: .PHONY mkdocs build; \ fi -docs-deps: .PHONY +venv: + python3 -m venv ./venv + +docs-deps: venv .PHONY + . venv/bin/activate && \ pip3 install -r requirements.txt -docs-deps-update: .PHONY +docs-deps-update: venv .PHONY + . venv/bin/activate && \ pip3 install -r requirements.txt --upgrade diff --git a/client/client.go b/client/client.go index b744fa1..93cf7da 100644 --- a/client/client.go +++ b/client/client.go @@ -11,23 +11,25 @@ import ( "heckel.io/ntfy/util" "io" "net/http" + "regexp" "strings" "sync" "time" ) -// Event type constants const ( - MessageEvent = "message" - KeepaliveEvent = "keepalive" - OpenEvent = "open" - PollRequestEvent = "poll_request" + // MessageEvent identifies a message event + MessageEvent = "message" ) const ( maxResponseBytes = 4096 ) +var ( + topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go +) + // Client is the ntfy client that can be used to publish and subscribe to ntfy topics type Client struct { Messages chan *Message @@ -96,8 +98,14 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // WithNoFirebase, and the generic WithHeader. func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { - topicURL := c.expandTopicURL(topic) - req, _ := http.NewRequest("POST", topicURL, body) + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", topicURL, body) + if err != nil { + return nil, err + } for _, option := range options { if err := option(req); err != nil { return nil, err @@ -133,11 +141,14 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO // By default, all messages will be returned, but you can change this behavior using a SubscribeOption. // See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam. func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return nil, err + } ctx := context.Background() messages := make([]*Message, 0) msgChan := make(chan *Message) errChan := make(chan error) - topicURL := c.expandTopicURL(topic) log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL)) options = append(options, WithPoll()) go func() { @@ -166,15 +177,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err // Example: // // c := client.New(client.NewConfig()) -// subscriptionID := c.Subscribe("mytopic") +// subscriptionID, _ := c.Subscribe("mytopic") // for m := range c.Messages { // fmt.Printf("New message: %s", m.Message) // } -func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { +func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) { + topicURL, err := c.expandTopicURL(topic) + if err != nil { + return "", err + } c.mu.Lock() defer c.mu.Unlock() subscriptionID := util.RandomString(10) - topicURL := c.expandTopicURL(topic) log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL)) ctx, cancel := context.WithCancel(context.Background()) c.subscriptions[subscriptionID] = &subscription{ @@ -183,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string { cancel: cancel, } go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...) - return subscriptionID + return subscriptionID, nil } // Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique @@ -199,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) { sub.cancel() } -// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe. -// If there are multiple subscriptions matching the topic, all of them are unsubscribed from. -// -// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// -// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the -// config (e.g. mytopic -> https://ntfy.sh/mytopic). -func (c *Client) UnsubscribeAll(topic string) { - c.mu.Lock() - defer c.mu.Unlock() - topicURL := c.expandTopicURL(topic) - for _, sub := range c.subscriptions { - if sub.topicURL == topicURL { - delete(c.subscriptions, sub.ID) - sub.cancel() - } - } -} - -func (c *Client) expandTopicURL(topic string) string { +func (c *Client) expandTopicURL(topic string) (string, error) { if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") { - return topic + return topic, nil } else if strings.Contains(topic, "/") { - return fmt.Sprintf("https://%s", topic) + return fmt.Sprintf("https://%s", topic), nil } - return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic) + if !topicRegex.MatchString(topic) { + return "", fmt.Errorf("invalid topic name: %s", topic) + } + return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil } func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) { diff --git a/client/client_test.go b/client/client_test.go index a71ea5c..f0b15a3 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) { defer test.StopServer(t, s, port) c := client.New(newTestConfig(port)) - subscriptionID := c.Subscribe("mytopic") + subscriptionID, _ := c.Subscribe("mytopic") time.Sleep(time.Second) msg, err := c.Publish("mytopic", "some message") diff --git a/cmd/subscribe.go b/cmd/subscribe.go index 2691e6a..c85c468 100644 --- a/cmd/subscribe.go +++ b/cmd/subscribe.go @@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND $NTFY_TITLE $title, $t Message title $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max) $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list) - $NTFY_RAW $raw Raw JSON message + $NTFY_RAW $raw Raw JSON message Examples: ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages @@ -194,7 +194,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, topicOptions = append(topicOptions, auth) } - subscriptionID := cl.Subscribe(s.Topic, topicOptions...) + subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...) + if err != nil { + return err + } if s.Command != "" { cmds[subscriptionID] = s.Command } else if conf.DefaultCommand != "" { @@ -204,7 +207,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, } } if topic != "" { - subscriptionID := cl.Subscribe(topic, options...) + subscriptionID, err := cl.Subscribe(topic, options...) + if err != nil { + return err + } cmds[subscriptionID] = command } for m := range cl.Messages { diff --git a/docs/develop.md b/docs/develop.md index a53c503..baab3f3 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -163,6 +163,15 @@ $ make release-snapshot During development, you may want to be more picky and build only certain things. Here are a few examples. +### Build a Docker image only for Linux + +This is useful to test the final build with web app, docs, and server without any dependencies locally + +``` shell +$ make docker-dev +$ docker run --rm -p 80:80 binwiederhier/ntfy:dev serve +``` + ### Build the ntfy binary To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` targets: diff --git a/docs/releases.md b/docs/releases.md index 52fba34..0e93b67 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1225,9 +1225,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes:** * Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) +* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting) +* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting) **Maintenance:** * Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) * Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost)) * Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost)) diff --git a/server/server.go b/server/server.go index ac54aa5..d2fac01 100644 --- a/server/server.go +++ b/server/server.go @@ -760,7 +760,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.config.TwilioAccount != "" && call != "" { go s.callPhone(v, r, m, call) } - if s.config.UpstreamBaseURL != "" { + if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream go s.forwardPollRequest(v, m) } } else { diff --git a/server/server_test.go b/server/server_test.go index 73df276..d7c4a7c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2559,6 +2559,29 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { }) } +func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) { + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("UnifiedPush messages should not be forwarded") + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) + + // Send UP message, this should not forward to upstream server + response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + + // Forwarding is done asynchronously, so wait a bit. + // This ensures that the t.Fatal above is actually not triggered. + time.Sleep(500 * time.Millisecond) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/web/package-lock.json b/web/package-lock.json index 3781278..b5754d9 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -405,25 +405,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@emotion/cache": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", @@ -1708,6 +1689,14 @@ "node": ">=4" } }, + "node_modules/chalk/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -2084,11 +2073,14 @@ } }, "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "engines": { - "node": ">=0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { @@ -2472,18 +2464,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", @@ -4202,9 +4182,9 @@ } }, "node_modules/source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "engines": { "node": ">=0.10.0" } @@ -4240,6 +4220,14 @@ "stackframe": "^1.3.4" } }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stacktrace-js": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index aeff195..6b967c8 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -355,5 +355,15 @@ "account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich webových stránkách.", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail" + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail", + "publish_dialog_call_label": "Telefonát", + "publish_dialog_call_reset": "Odstranit telefonát", + "publish_dialog_chip_call_label": "Telefonát", + "account_basics_phone_numbers_title": "Telefonní čísla", + "account_basics_phone_numbers_dialog_description": "Pro oznámení prostřednictvím tel. hovoru, musíte přidat a ověřit alespoň jedno telefonní číslo. Ověření lze provést pomocí SMS nebo telefonátu.", + "account_basics_phone_numbers_description": "K oznámení telefonátem", + "account_basics_phone_numbers_no_phone_numbers_yet": "Zatím žádná telefonní čísla", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonní číslo zkopírováno do schránky", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Žádná ověřená telefonní čísla", + "publish_dialog_call_item": "Vytočit číslo {{number}}" } diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 3166a52..62ecdaf 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -355,5 +355,31 @@ "account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor contáctenos directamente.", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} mensaje diario", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} correo electrónico diario", - "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado" + "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} tema reservado", + "publish_dialog_call_label": "Llamada telefónica", + "publish_dialog_call_placeholder": "Número de teléfono al cual llamar con el mensaje, por ejemplo +12223334444, o \"sí\"", + "publish_dialog_chip_call_label": "Llamada telefónica", + "account_basics_phone_numbers_title": "Números de teléfono", + "account_basics_phone_numbers_description": "Para notificaciones por llamada teléfonica", + "account_basics_phone_numbers_no_phone_numbers_yet": "Aún no hay números de teléfono", + "account_basics_phone_numbers_dialog_number_label": "Número de teléfono", + "account_basics_phone_numbers_dialog_number_placeholder": "p. ej. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Envía SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Llámame", + "account_basics_phone_numbers_dialog_code_label": "Código de verificación", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Llamar", + "account_usage_calls_title": "Llamadas telefónicas realizadas", + "account_usage_calls_none": "No se pueden hacer llamadas telefónicas con esta cuenta", + "account_upgrade_dialog_tier_features_calls_one": "{{llamadas}} llamadas telefónicas diarias", + "account_upgrade_dialog_tier_features_calls_other": "{{llamadas}} llamadas telefónicas diarias", + "account_upgrade_dialog_tier_features_no_calls": "No hay llamadas telefónicas", + "publish_dialog_call_reset": "Eliminar llamada telefónica", + "account_basics_phone_numbers_dialog_description": "Para utilizar la función de notificación de llamadas, tiene que añadir y verificar al menos un número de teléfono. La verificación puede realizarse mediante un SMS o una llamada telefónica.", + "account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles", + "account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código", + "account_basics_phone_numbers_dialog_title": "Agregar número de teléfono", + "account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456", + "publish_dialog_call_item": "Llamar al número de teléfono {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados" } diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 76c9d1d..48fcda0 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -379,5 +379,7 @@ "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian", "account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon", - "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi" + "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi", + "publish_dialog_call_item": "Panggil nomor telepon {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi" } diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index ca7a2a1..8ccb629 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -355,5 +355,30 @@ "prefs_reservations_table_topic_header": "Onderwerp", "prefs_reservations_table_access_header": "Toegang", "prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren", - "prefs_reservations_table_not_subscribed": "Niet geabonneerd" + "prefs_reservations_table_not_subscribed": "Niet geabonneerd", + "publish_dialog_call_label": "Telefoongesprek", + "publish_dialog_call_reset": "Telefoongesprek verwijderen", + "publish_dialog_chip_call_label": "Telefoongesprek", + "account_basics_phone_numbers_title": "Telefoonnummers", + "account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken", + "account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers", + "account_basics_phone_numbers_dialog_verify_button_call": "Bel me", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes", + "account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord", + "publish_dialog_call_item": "Bel telefoonnummer {{nummer}}", + "account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers", + "account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek", + "account_basics_phone_numbers_dialog_number_label": "Telefoonnummer", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes", + "account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes", + "account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.", + "account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen", + "account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS", + "account_basics_phone_numbers_dialog_code_label": "Verificatiecode", + "account_usage_calls_title": "Aantal telefoontjes", + "account_usage_calls_none": "Met dit account kan niet worden gebeld" } diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index bf753c9..57d5656 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -214,5 +214,17 @@ "login_link_signup": "Registar", "action_bar_reservation_add": "Reservar tópico", "action_bar_sign_up": "Registar", - "nav_button_account": "Conta" + "nav_button_account": "Conta", + "common_copy_to_clipboard": "Copiar", + "nav_upgrade_banner_label": "Atualizar para ntfy Pro", + "alert_not_supported_context_description": "Notificações são suportadas apenas sobre HTTPS. Essa é uma limitação da API de Notificações.", + "display_name_dialog_title": "Alterar nome mostrado", + "display_name_dialog_description": "Configura um nome alternativo ao tópico que é mostrado na lista de assinaturas. Isto ajuda a identificar tópicos com nomes complicados mais facilmente.", + "display_name_dialog_placeholder": "Nome exibido", + "reserve_dialog_checkbox_label": "Reservar tópico e configurar acesso", + "publish_dialog_call_label": "Chamada telefônica", + "publish_dialog_call_placeholder": "Número de telefone para ligar com a mensagem, ex: +12223334444, ou 'Sim'", + "publish_dialog_call_reset": "Remover chamada telefônica", + "publish_dialog_chip_call_label": "Chamada telefônica", + "subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome" } diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index 31e809c..bc4a540 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -355,5 +355,30 @@ "reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor", "reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.", "reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet", - "reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor." + "reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.", + "publish_dialog_call_label": "Telefonsamtal", + "publish_dialog_call_reset": "Ta bort telefonsamtal", + "publish_dialog_chip_call_label": "Telefonsamtal", + "account_basics_phone_numbers_title": "Telefonnummer", + "account_basics_phone_numbers_description": "För notifieringar via telefonsamtal", + "account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp", + "account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer", + "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", + "account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Ring mig", + "account_basics_phone_numbers_dialog_code_label": "Verifieringskod", + "account_basics_phone_numbers_dialog_channel_call": "Ring", + "account_usage_calls_title": "Telefonsamtal som gjorts", + "account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto", + "publish_dialog_call_item": "Ring telefonnummer {{number}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer", + "account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.", + "account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal", + "account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal" } diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 7d995dd..32a3079 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -352,5 +352,34 @@ "account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, будь ласка, видаліть принаймні {{count}} резервувань. Ви можете видалити резервування в Налаштуваннях.", "account_upgrade_dialog_button_cancel": "Скасувати", "account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз", - "account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися" + "account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися", + "prefs_reservations_add_button": "Додати зарезервовану тему", + "prefs_reservations_edit_button": "Редагувати доступ до теми", + "prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.", + "prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися", + "prefs_reservations_table_topic_header": "Тема", + "prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.", + "prefs_reservations_table": "Таблиця зарезервованих тем", + "prefs_reservations_table_access_header": "Доступ", + "prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись", + "prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися", + "prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати", + "prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися", + "prefs_reservations_table_not_subscribed": "Не підписаний", + "prefs_reservations_dialog_title_add": "Зарезервувати тему", + "prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему", + "prefs_reservations_title": "Зарезервовані теми", + "prefs_reservations_delete_button": "Скинути доступ до теми", + "prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.", + "prefs_reservations_dialog_topic_label": "Тема", + "prefs_reservations_dialog_access_label": "Доступ", + "reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.", + "reservation_delete_dialog_submit_button": "Видалити резервування", + "publish_dialog_call_item": "Телефонувати за номером {{номер}}", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів", + "prefs_reservations_dialog_title_delete": "Видалити резервування теми", + "reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень", + "reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень", + "reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.", + "reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована." } diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 751c7bd..2033cbe 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -61,9 +61,7 @@ class ConnectionManager { const { connectionId } = subscription; const added = !this.connections.get(connectionId); if (added) { - const { baseUrl } = subscription; - const { topic } = subscription; - const { user } = subscription; + const { baseUrl, topic, user } = subscription; const since = subscription.last; const connection = new Connection( connectionId, diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index b956821..372e46e 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -21,15 +21,16 @@ class Poller { async pollAll() { console.log(`[Poller] Polling all subscriptions`); const subscriptions = await subscriptionManager.all(); - for (const s of subscriptions) { - try { - // TODO(eslint): Switch to Promise.all - // eslint-disable-next-line no-await-in-loop - await this.poll(s); - } catch (e) { - console.log(`[Poller] Error polling ${s.id}`, e); - } - } + + await Promise.all( + subscriptions.map(async (s) => { + try { + await this.poll(s); + } catch (e) { + console.log(`[Poller] Error polling ${s.id}`, e); + } + }) + ); } async poll(subscription) { diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 7762753..ecbe4da 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -5,13 +5,12 @@ class SubscriptionManager { /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ async all() { const subscriptions = await db.subscriptions.toArray(); - await Promise.all( - subscriptions.map(async (s) => { - // eslint-disable-next-line no-param-reassign - s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(); - }) + return Promise.all( + subscriptions.map(async (s) => ({ + ...s, + new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(), + })) ); - return subscriptions; } async get(subscriptionId) { @@ -40,33 +39,31 @@ class SubscriptionManager { console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); // Add remote subscriptions - const remoteIds = []; // = topicUrl(baseUrl, topic) - for (let i = 0; i < remoteSubscriptions.length; i += 1) { - const remote = remoteSubscriptions[i]; - // TODO(eslint): Switch to Promise.all - // eslint-disable-next-line no-await-in-loop - const local = await this.add(remote.base_url, remote.topic, false); - const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; - // TODO(eslint): Switch to Promise.all - // eslint-disable-next-line no-await-in-loop - await this.update(local.id, { - displayName: remote.display_name, // May be undefined - reservation, // May be null! - }); - remoteIds.push(local.id); - } + const remoteIds = await Promise.all( + remoteSubscriptions.map(async (remote) => { + const local = await this.add(remote.base_url, remote.topic, false); + const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; + + await this.update(local.id, { + displayName: remote.display_name, // May be undefined + reservation, // May be null! + }); + + return local.id; + }) + ); // Remove local subscriptions that do not exist remotely const localSubscriptions = await db.subscriptions.toArray(); - for (let i = 0; i < localSubscriptions.length; i += 1) { - const local = localSubscriptions[i]; - const remoteExists = remoteIds.includes(local.id); - if (!local.internal && !remoteExists) { - // TODO(eslint): Switch to Promise.all - // eslint-disable-next-line no-await-in-loop - await this.remove(local.id); - } - } + + await Promise.all( + localSubscriptions.map(async (local) => { + const remoteExists = remoteIds.includes(local.id); + if (!local.internal && !remoteExists) { + await this.remove(local.id); + } + }) + ); } async updateState(subscriptionId, state) { @@ -108,9 +105,12 @@ class SubscriptionManager { return false; } try { - // eslint-disable-next-line no-param-reassign - notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab + await db.notifications.add({ + ...notification, + subscriptionId, + // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + new: 1, + }); // FIXME consider put() for double tab await db.subscriptions.update(subscriptionId, { last: notification.id, }); diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 0af1033..ab7551b 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -118,10 +118,10 @@ export const maybeWithBearerAuth = (headers, token) => { export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) }); export const maybeWithAuth = (headers, user) => { - if (user && user.password) { + if (user?.password) { return withBasicAuth(headers, user.username, user.password); } - if (user && user.token) { + if (user?.token) { return withBearerAuth(headers, user.token); } return headers; @@ -139,17 +139,14 @@ export const maybeAppendActionErrors = (message, notification) => { }; export const shuffle = (arr) => { - let j; - let x; - for (let index = arr.length - 1; index > 0; index -= 1) { - j = Math.floor(Math.random() * (index + 1)); - x = arr[index]; - // eslint-disable-next-line no-param-reassign - arr[index] = arr[j]; - // eslint-disable-next-line no-param-reassign - arr[j] = x; + const returnArr = [...arr]; + + for (let index = returnArr.length - 1; index > 0; index -= 1) { + const j = Math.floor(Math.random() * (index + 1)); + [returnArr[index], returnArr[j]] = [returnArr[j], returnArr[index]]; } - return arr; + + return returnArr; }; export const splitNoEmpty = (s, delimiter) => diff --git a/web/src/components/EmojiPicker.jsx b/web/src/components/EmojiPicker.jsx index f9e8b5e..d1fb170 100644 --- a/web/src/components/EmojiPicker.jsx +++ b/web/src/components/EmojiPicker.jsx @@ -127,17 +127,7 @@ const Category = (props) => { ); }; -const emojiMatches = (emoji, words) => { - if (words.length === 0) { - return true; - } - for (const word of words) { - if (emoji.searchBase.indexOf(word) === -1) { - return false; - } - } - return true; -}; +const emojiMatches = (emoji, words) => words.length === 0 || words.some((word) => emoji.searchBase.includes(word)); const Emoji = (props) => { const { emoji } = props; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 7d4da06..2faf2fd 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -436,15 +436,10 @@ const ACTION_LABEL_SUFFIX = { }; const updateActionStatus = (notification, action, progress, error) => { - // TODO(eslint): Fix by spreading? Does the code depend on the change, though? - // eslint-disable-next-line no-param-reassign - notification.actions = notification.actions.map((a) => { - if (a.id !== action.id) { - return a; - } - return { ...a, progress, error }; + subscriptionManager.updateNotification({ + ...notification, + actions: notification.actions.map((a) => (a.id === action.id ? { ...a, progress, error } : a)), }); - subscriptionManager.updateNotification(notification); }; const performHttpAction = async (notification, action) => { diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index b9c5536..6b68188 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -47,6 +47,13 @@ export const useConnectionListeners = (account, subscriptions, users) => { const handleMessage = async (subscriptionId, message) => { const subscription = await subscriptionManager.get(subscriptionId); + + // Race condition: sometimes the subscription is already unsubscribed from account + // sync before the message is handled + if (!subscription) { + return; + } + if (subscription.internal) { await handleInternalMessage(message); } else {