Compare commits

..

1 Commits

Author SHA1 Message Date
Philipp Heckel
96a12d98c9 WIP: Templating 2022-03-15 11:39:51 -04:00
52 changed files with 2777 additions and 4835 deletions

View File

@@ -4,7 +4,7 @@ before:
- go mod tidy
builds:
-
id: ntfy_amd64
id: ntfy
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
@@ -28,8 +28,9 @@ builds:
goos: [linux]
goarch: [arm]
goarm: [7]
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
hooks:
post:
- upx "{{ .Path }}" # apt install upx
-
id: ntfy_arm64
binary: ntfy
@@ -41,8 +42,9 @@ builds:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
# No "upx", since it causes random core dumps, see
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
hooks:
post:
- upx "{{ .Path }}" # apt install upx
nfpms:
-
package_name: ntfy

View File

@@ -2,6 +2,4 @@ FROM alpine
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
COPY ntfy /usr/bin
EXPOSE 80/tcp
ENTRYPOINT ["ntfy"]

195
Makefile
View File

@@ -3,87 +3,55 @@ VERSION := $(shell git describe --tag)
.PHONY:
help:
@echo "Typical commands (more see below):"
@echo " make build - Build web app, documentation and server/client (sloowwww)"
@echo " make server-amd64 - Build server/client binary (amd64, no web app or docs)"
@echo " make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)"
@echo " make web - Build the web app"
@echo " make docs - Build the documentation"
@echo " make check - Run all tests, vetting/formatting checks and linters"
@echo
@echo "Build everything:"
@echo " make build - Build web app, documentation and server/client"
@echo " make clean - Clean build/dist folders"
@echo
@echo "Build server & client (not release version):"
@echo " make server - Build server & client (all architectures)"
@echo " make server-amd64 - Build server & client (amd64 only)"
@echo " make server-armv7 - Build server & client (armv7 only)"
@echo " make server-arm64 - Build server & client (arm64 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
@echo "Build documentation:"
@echo " make docs - Build the documentation"
@echo " make docs-deps - Install Python dependencies (pip3 install)"
@echo " make docs-build - Actually build the documentation"
@echo "Typical commands:"
@echo " make check - Run all tests, vetting/formatting checks and linters"
@echo " make fmt build-snapshot install - Build latest and install to local system"
@echo
@echo "Test/check:"
@echo " make test - Run tests"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@echo " make coverage-upload - Upload coverage results to codecov.io"
@echo " make test - Run tests"
@echo " make race - Run tests with -race flag"
@echo " make coverage - Run tests and show coverage"
@echo " make coverage-html - Run tests and show coverage (as HTML)"
@echo " make coverage-upload - Upload coverage results to codecov.io"
@echo
@echo "Lint/format:"
@echo " make fmt - Run 'go fmt'"
@echo " make fmt-check - Run 'go fmt', but don't change anything"
@echo " make vet - Run 'go vet'"
@echo " make lint - Run 'golint'"
@echo " make staticcheck - Run 'staticcheck'"
@echo " make fmt - Run 'go fmt'"
@echo " make fmt-check - Run 'go fmt', but don't change anything"
@echo " make vet - Run 'go vet'"
@echo " make lint - Run 'golint'"
@echo " make staticcheck - Run 'staticcheck'"
@echo
@echo "Releasing:"
@echo " make release - Create a release"
@echo " make release-snapshot - Create a test release"
@echo "Build:"
@echo " make build - Build"
@echo " make build-snapshot - Build snapshot"
@echo " make build-simple - Build (using go build, without goreleaser)"
@echo " make clean - Clean build folder"
@echo
@echo "Releasing (requires goreleaser):"
@echo " make release - Create a release"
@echo " make release-snapshot - Create a test release"
@echo
@echo "Install locally (requires sudo):"
@echo " make install-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
@echo " make install-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
@echo " make install-deb-amd64 - Install .deb from dist/ (amd64 only)"
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
# Building everything
clean: .PHONY
rm -rf dist build server/docs server/site
build: web docs server
@echo " make install - Copy binary from dist/ to /usr/bin"
@echo " make install-deb - Install .deb from dist/"
@echo " make install-lint - Install golint"
# Documentation
docs: docs-deps docs-build
docs-deps: .PHONY
pip3 install -r requirements.txt
docs-build: .PHONY
docs: docs-deps
mkdocs build
# Web app
web: web-deps web-build
web-deps:
cd web && npm install
# If this fails for .svg files, optimizes them with svgo
cd web \
&& npm install \
&& node_modules/svgo/bin/svgo src/img/*.svg
web-build:
cd web \
@@ -95,37 +63,7 @@ web-build:
../server/site/config.js \
../server/site/asset-manifest.json
# Main server/client build
server: server-deps
goreleaser build --snapshot --rm-dist --debug
server-amd64: server-deps-static-sites
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
server-armv7: server-deps-static-sites server-deps-gcc-armv7
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
server-arm64: server-deps-static-sites server-deps-gcc-arm64
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
server-deps-gcc: server-deps-gcc-armv7 server-deps-gcc-arm64
server-deps-static-sites:
mkdir -p server/docs server/site
touch server/docs/index.html server/site/app.html
server-deps-all:
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
server-deps-gcc-armv7:
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
server-deps-gcc-arm64:
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
web: web-deps web-build
# Test/check targets
@@ -176,51 +114,64 @@ staticcheck: .PHONY
rm -rf build/staticcheck
# Building targets
build-deps: docs web
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
build: build-deps
goreleaser build --rm-dist --debug
build-snapshot: build-deps
goreleaser build --snapshot --rm-dist --debug
build-simple: clean
mkdir -p dist/ntfy_linux_amd64 server/docs server/site
touch server/docs/index.html
touch server/site/app.html
export CGO_ENABLED=1
go build \
-o dist/ntfy_linux_amd64/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
clean: .PHONY
rm -rf dist build server/docs server/site
# Releasing targets
release: release-deps
goreleaser release --rm-dist --debug
release-snapshot: release-deps
goreleaser release --snapshot --skip-publish --rm-dist --debug
release-deps: clean server-deps release-check-tags docs web check
release-check-tags:
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
if ! grep -q $(LATEST_TAG) docs/install.md; then\
echo "ERROR: Must update docs/install.md with latest tag first.";\
exit 1;\
fi
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
echo "ERROR: Must update docs/releases.md with latest tag first.";\
if grep -q XXXXX docs/releases.md; then\
echo "ERROR: Must update docs/releases.md, found XXXXX.";\
exit 1;\
fi
if ! grep -q $(LATEST_TAG) docs/releases.md; then\
echo "ERROR: Must update docs/releases.mdwith latest tag first.";\
exit 1;\
fi
release: build-deps release-check-tags check
goreleaser release --rm-dist --debug
release-snapshot: build-deps
goreleaser release --snapshot --skip-publish --rm-dist --debug
# Installing targets
install-amd64: remove-binary
sudo cp -a dist/ntfy_amd64_linux_amd64/ntfy /usr/bin/ntfy
install-armv7: remove-binary
sudo cp -a dist/ntfy_armv7_linux_armv7/ntfy /usr/bin/ntfy
install-arm64: remove-binary
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
remove-binary:
install:
sudo rm -f /usr/bin/ntfy
sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy
install-amd64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
install-armv7-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
install-arm64-deb: purge-package
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
purge-package:
install-deb:
sudo systemctl stop ntfy || true
sudo apt-get purge ntfy || true
sudo dpkg -i dist/ntfy_*_linux_amd64.deb

View File

@@ -34,14 +34,7 @@ too.
[Building](https://ntfy.sh/docs/develop/)
## Contributing
I welcome any and all contributions. Just create a PR or an issue. To contribute code, check out
the [build instructions](https://ntfy.sh/docs/develop/) for the server and the Android app.
Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start immediately in
[Hosted Weblate](https://hosted.weblate.org/projects/ntfy/).
<a href="https://hosted.weblate.org/engage/ntfy/">
<img src="https://hosted.weblate.org/widgets/ntfy/-/multi-blue.svg" alt="Translation status" />
</a>
I welcome any and all contributions. Just create a PR or an issue.
## Contact me
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)

View File

@@ -34,6 +34,7 @@ var flagsServe = []cli.Flag{
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"M"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, DefaultText: "4K", Usage: "size limit of messages before they are treated as attachments (e.g. 4K, 64K)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
@@ -95,6 +96,7 @@ func execServe(c *cli.Context) error {
keepaliveInterval := c.Duration("keepalive-interval")
managerInterval := c.Duration("manager-interval")
webRoot := c.String("web-root")
messageSizeLimitStr := c.String("message-size-limit")
smtpSenderAddr := c.String("smtp-sender-addr")
smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass")
@@ -171,6 +173,12 @@ func execServe(c *cli.Context) error {
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
}
messageSizeLimit, err := parseSize(messageSizeLimitStr, server.DefaultMessageLengthLimit)
if err != nil {
return err
} else if messageSizeLimit > server.MaxMessageLengthLimit {
return fmt.Errorf("config option message-size-limit must be lower than %d", server.MaxMessageLengthLimit)
}
// Resolve hosts
visitorRequestLimitExemptIPs := make([]string, 0)
@@ -206,6 +214,7 @@ func execServe(c *cli.Context) error {
conf.KeepaliveInterval = keepaliveInterval
conf.ManagerInterval = managerInterval
conf.WebRootIsApp = webRootIsApp
conf.MessageLimit = int(messageSizeLimit)
conf.SMTPSenderAddr = smtpSenderAddr
conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass

View File

@@ -25,6 +25,7 @@ var cmdUser = &cli.Command{
Aliases: []string{"a"},
Usage: "Adds a new user",
UsageText: "ntfy user add [--role=admin|user] USERNAME",
Before: inheritRootReaderFunc,
Action: execUserAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
@@ -45,6 +46,7 @@ Examples:
Aliases: []string{"del", "rm"},
Usage: "Removes a user",
UsageText: "ntfy user remove USERNAME",
Before: inheritRootReaderFunc,
Action: execUserDel,
Description: `Remove a user from the ntfy user database.
@@ -57,6 +59,7 @@ Example:
Aliases: []string{"chp"},
Usage: "Changes a user's password",
UsageText: "ntfy user change-pass USERNAME",
Before: inheritRootReaderFunc,
Action: execUserChangePass,
Description: `Change the password for the given user.
@@ -72,6 +75,7 @@ Example:
Aliases: []string{"chr"},
Usage: "Changes the role of a user",
UsageText: "ntfy user change-role USERNAME ROLE",
Before: inheritRootReaderFunc,
Action: execUserChangeRole,
Description: `Change the role for the given user to admin or user.
@@ -93,6 +97,7 @@ Example:
Name: "list",
Aliases: []string{"l"},
Usage: "Shows a list of users",
Before: inheritRootReaderFunc,
Action: execUserList,
Description: `Shows a list of all configured users, including the everyone ('*') user.
@@ -270,3 +275,14 @@ func userCommandFlags() []cli.Flag {
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
}
}
// inheritRootReaderFunc is a workaround for a urfave/cli bug that makes subcommands not inherit the App.Reader.
// This bug was fixed in master, but not in v2.3.0.
func inheritRootReaderFunc(ctx *cli.Context) error {
for _, c := range ctx.Lineage() {
if c.App != nil && c.App.Reader != nil {
ctx.App.Reader = c.App.Reader
}
}
return nil
}

View File

@@ -346,7 +346,7 @@ statuspage.io (though these days most services also support webhooks and HTTP ca
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh` (must be identical to MX record, see below)
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
accepted (which may obviously be a spam problem).
@@ -369,42 +369,6 @@ configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
<figcaption>DNS records for incoming mail</figcaption>
</figure>
You can check if everything is working correctly by sending an email as raw SMTP via `nc`. Create a text file, e.g.
`email.txt`
```
EHLO example.com
MAIL FROM: phil@example.com
RCPT TO: ntfy-mytopic@ntfy.sh
DATA
Subject: Email for you
Content-Type: text/plain; charset="UTF-8"
Hello from 🇩🇪
.
```
And then send the mail via `nc` like this. If you see any lines starting with `451`, those are errors from the
ntfy server. Read them carefully.
```
$ cat email.txt | nc -N ntfy.sh 25
220 ntfy.sh ESMTP Service Ready
250-Hello example.com
...
250 2.0.0 Roger, accepting mail from <phil@example.com>
250 2.0.0 I'll make sure <ntfy-mytopic@ntfy.sh> gets this
```
As for the DNS setup, be sure to verify that `dig MX` and `dig A` are returning results similar to this:
```
$ dig MX ntfy.sh +short
10 mx1.ntfy.sh.
$ dig A mx1.ntfy.sh +short
3.139.215.220
```
## Behind a proxy (TLS, etc.)
!!! warning
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are

View File

@@ -5,17 +5,13 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
## Active deprecations
### Android app: WebSockets will become the default connection protocol
> Active since 2022-03-13, behavior will change in **June 2022**
> since 2022-03-13, behavior will change in **June 2022**
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
WebSocket in June.
### Android app: Using `since=<timestamp>` instead of `since=<id>`
> Active since 2022-02-27, behavior will change in **May 2022**
> since 2022-02-27, behavior will change in **May 2022**
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
@@ -25,7 +21,7 @@ The `since=<timestamp>` endpoint will continue to work. This is merely a notice
## Previous deprecations
### Running server via `ntfy` (instead of `ntfy serve`)
> Deprecated 2021-12-17, behavior changed with v1.10.0
> deprecated 2021-12-17, behavior changed with v1.10.0
As more commands are added to the `ntfy` CLI tool, using just `ntfy` to run the server is not practical
anymore. Please use `ntfy serve` instead. This also applies to Docker images, as they can also execute more than

View File

@@ -1,287 +1,51 @@
# Development
Hurray 🥳 🎉, you are interested in writing code for ntfy! **That's awesome.** 😎
I tried my very best to write up detailed instructions, but if at any point in time you run into issues, don't
hesitate to **contact me on [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)**.
# Building
## ntfy server
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy). The codebase for the
server consists of three components:
* **The main server/client** is written in [Go](https://go.dev/) (so you'll need Go). Its main entrypoint is at
[main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go), and the meat you're likely interested in is
in [server.go](https://github.com/binwiederhier/ntfy/blob/main/server/server.go). Notably, the server uses a
[SQLite](https://sqlite.org) library called [go-sqlite3](https://github.com/mattn/go-sqlite3), which requires
[Cgo](https://go.dev/blog/cgo) and `CGO_ENABLED=1` to be set. Otherwise things will not work (see below).
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
build the docs.
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
and install all the 100,000 dependencies (*sigh*).
All of these components are built and then **baked into one binary**.
### Navigating the code
Code:
* [main.go](https://github.com/binwiederhier/ntfy/blob/main/main.go) - Main entrypoint into the CLI, for both server and client
* [cmd/](https://github.com/binwiederhier/ntfy/tree/main/cmd) - CLI commands, such as `serve` or `publish`
* [server/](https://github.com/binwiederhier/ntfy/tree/main/server) - The meat of the server logic
* [docs/](https://github.com/binwiederhier/ntfy/tree/main/docs) - The [MkDocs](https://www.mkdocs.org/) documentation, also see `mkdocs.yml`
* [web/](https://github.com/binwiederhier/ntfy/tree/main/web) - The [React](https://reactjs.org/) application, also see `web/package.json`
Build related:
* [Makefile](https://github.com/binwiederhier/ntfy/blob/main/Makefile) - Main entrypoint for all things related to building
* [.goreleaser.yml](https://github.com/binwiederhier/ntfy/blob/main/.goreleaser.yml) - Describes all build outputs (for [GoReleaser](https://goreleaser.com/))
* [go.mod](https://github.com/binwiederhier/ntfy/blob/main/go.mod) - Go modules dependency file
* [mkdocs.yml](https://github.com/binwiederhier/ntfy/blob/main/mkdocs.yml) - Config file for the docs (for [MkDocs](https://www.mkdocs.org/))
* [web/package.json](https://github.com/binwiederhier/ntfy/blob/main/web/package.json) - Build and dependency file for web app (for npm)
The `web/` and `docs/` folder are the sources for web app and documentation. During the build process,
the generated output is copied to `server/site` (web app and landing page) and `server/docs` (documentation).
### Build requirements
* [Go](https://go.dev/) (required for main server)
* [gcc](https://gcc.gnu.org/) (required main server, for SQLite cgo-based bindings)
* [Make](https://www.gnu.org/software/make/) (required for convenience)
* [libsqlite3/libsqlite3-dev](https://www.sqlite.org/) (required for main server, for SQLite cgo-based bindings)
* [GoReleaser](https://goreleaser.com/) (required for a proper main server build)
* [Python](https://www.python.org/) (for `pip`, only to build the docs)
* [nodejs](https://nodejs.org/en/) (for `npm`, only to build the web app)
### Install dependencies
These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```
Install [GoReleaser](https://goreleaser.com/) (see [official instructions](https://goreleaser.com/install/)):
``` shell
go install github.com/goreleaser/goreleaser@latest
goreleaser -v # verifies that it worked
```
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
``` shell
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
sudo apt-get install -y nodejs
npm -v # verifies that it worked
```
Then install a few other things required:
``` shell
sudo apt install \
build-essential \
libsqlite3-dev \
gcc-arm-linux-gnueabi \
gcc-aarch64-linux-gnu \
python3-pip \
upx \
git
```
### Check out code
Now check out via git from the [GitHub repository](https://github.com/binwiederhier/ntfy):
=== "via HTTPS"
``` shell
git clone https://github.com/binwiederhier/ntfy.git
cd ntfy
```
=== "via SSH"
``` shell
git clone git@github.com:binwiederhier/ntfy.git
cd ntfy
```
### Build all the things
Now you can finally build everything. There are tons of `make` targets, so maybe just review what's there first
by typing `make`:
``` shell
$ make
Typical commands (more see below):
make build - Build web app, documentation and server/client (sloowwww)
make server-amd64 - Build server/client binary (amd64, no web app or docs)
make install-amd64 - Install ntfy binary to /usr/bin/ntfy (amd64)
make web - Build the web app
make docs - Build the documentation
make check - Run all tests, vetting/formatting checks and linters
...
```
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and amd64),
you can simply run `make build`:
``` shell
$ make build
...
# This builds web app, docs, and the ntfy binary (for amd64, armv7 and arm64).
# This will be SLOW (5+ minutes on my laptop on the first run). Maybe look at the other make targets?
```
You'll see all the outputs in the `dist/` folder afterwards:
``` bash
$ find dist
dist
dist/metadata.json
dist/ntfy_arm64_linux_arm64
dist/ntfy_arm64_linux_arm64/ntfy
dist/ntfy_armv7_linux_arm_7
dist/ntfy_armv7_linux_arm_7/ntfy
dist/ntfy_amd64_linux_amd64
dist/ntfy_amd64_linux_amd64/ntfy
dist/config.yaml
dist/artifacts.json
```
If you also want to build the **Debian/RPM packages and the Docker images for all supported architectures**, you can
use the `make release-snapshot` target:
``` shell
$ make release-snapshot
...
# This will be REALLY SLOW (sometimes 5+ minutes on my laptop)
```
During development, you may want to be more picky and build only certain things. Here are a few examples.
### Build the ntfy binary
To build only the `ntfy` binary **without the web app or documentation**, use the `make server-...` targets:
``` shell
$ make
Build server & client (not release version):
make server - Build server & client (all architectures)
make server-amd64 - Build server & client (amd64 only)
make server-armv7 - Build server & client (armv7 only)
make server-arm64 - Build server & client (arm64 only)
```
So if you're on an amd64/x86_64-based machine, you may just want to run `make server-amd64` during testing. On a modern
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-amd64` so I can run the binary
right away:
``` shell
$ make server-amd64 install-amd64
$ ntfy serve
```
**During development of the main app, you can also just use `go run main.go`**, as long as you run
`make server-deps-static-sites`at least once and `CGO_ENABLED=1`:
``` shell
$ export CGO_ENABLED=1
$ make server-deps-static-sites
$ go run main.go serve
2022/03/18 08:43:55 Listening on :2586[http]
...
```
If you don't run `server-deps-static-sites`, you may see an error *`pattern ...: no matching files found`*:
```
$ go run main.go serve
server/server.go:85:13: pattern docs: no matching files found
```
This is because we use `go:embed` to embed the documentation and web app, so the Go code expects files to be
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `server-deps-static-sites`
target creates dummy files that ensures that you'll be able to build.
### Build the web app
The sources for the web app live in `web/`. As long as you have `npm` installed (see above), building the web app
is really simple. Just type `make web` and you're in business:
``` shell
$ make web
...
```
This will build the web app using Create React App and then **copy the production build to the `server/site` folder**, so
that when you `make server` (or `make server-amd64`, ...), you will have the web app included in the `ntfy` binary.
If you're developing on the web app, it's best to just `cd web` and run `npm start` manually. This will open your browser
at `http://127.0.0.1:3000` with the web app, and as you edit the source files, they will be recompiled and the browser
will automatically refresh:
``` shell
$ cd web
$ npm start
```
### Build the docs
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
documentation. As long as you have `mkdocs` installed (see above), this should work fine:
``` shell
$ make docs
...
```
If you are changing the documentation, you should be running `mkdocs serve` directly. This will build the documentation,
serve the files at `http://127.0.0.1:8000/`, and rebuild every time you save the source files:
The ntfy server source code is available [on GitHub](https://github.com/binwiederhier/ntfy).
To quickly build on amd64, you can use `make build-simple`:
```
$ mkdocs serve
INFO - Building documentation...
INFO - Cleaning site directory
INFO - Documentation built in 5.53 seconds
INFO - [16:28:14] Serving on http://127.0.0.1:8000/
git clone git@github.com:binwiederhier/ntfy.git
cd ntfy
make build-simple
```
Then you can navigate to http://127.0.0.1:8000/ and whenever you change a markdown file in your text editor it'll automatically update.
That'll generate a statically linked binary in `dist/ntfy_linux_amd64/ntfy`.
For all other platforms (including Docker), and for production or other snapshot builds, you should use the amazingly
awesome [GoReleaser](https://goreleaser.com/) make targets:
```
Build:
make build - Build
make build-snapshot - Build snapshot
make build-simple - Build (using go build, without goreleaser)
make clean - Clean build folder
Releasing (requires goreleaser):
make release - Create a release
make release-snapshot - Create a test release
```
There are currently no platform-specific make targets, so they will build for all platforms (which may take a while).
## Android app
The ntfy Android app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-android).
The Android app has two flavors:
* **Google Play:** The `play` flavor includes [Firebase (FCM)](https://firebase.google.com/) and requires a Firebase account
* **Google Play:** The `play` flavor includes Firebase (FCM) and requires a Firebase account
* **F-Droid:** The `fdroid` flavor does not include Firebase or Google dependencies
### Navigating the code
* [main/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/main) - Main Android app source code
* [play/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/play) - Google Play / Firebase specific code
* [fdroid/](https://github.com/binwiederhier/ntfy-android/tree/main/app/src/fdroid) - F-Droid Firebase stubs
* [build.gradle](https://github.com/binwiederhier/ntfy-android/blob/main/app/build.gradle) - Main build file
### IDE/Environment
You should download [Android Studio](https://developer.android.com/studio) (or [IntelliJ IDEA](https://www.jetbrains.com/idea/)
with the relevant Android plugins). Everything else will just be a pain for you. Do yourself a favor. 😀
### Check out the code
First check out the repository:
=== "via HTTPS"
``` shell
git clone https://github.com/binwiederhier/ntfy-android.git
cd ntfy-android
```
=== "via SSH"
``` shell
git clone git@github.com:binwiederhier/ntfy-android.git
cd ntfy-android
```
```
git clone git@github.com:binwiederhier/ntfy-android.git
cd ntfy-android
```
Then either follow the steps for building with or without Firebase.
### Build F-Droid flavor (no FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
work without issues. Please give me feedback if it does/doesn't work for you.
### Building without Firebase (F-Droid flavor)
Without Firebase, you may want to still change the default `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)
if you're self-hosting the server. Then run:
```
@@ -292,13 +56,8 @@ if you're self-hosting the server. Then run:
./gradlew bundleFdroidRelease
```
### Build Play flavor (FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
work without issues. Please give me feedback if it does/doesn't work for you.
### Building with Firebase (FCM, Google Play flavor)
To build your own version with Firebase, you must:
* Create a Firebase/FCM account
* Place your account file at `app/google-services.json`
* And change `app_base_url` in [strings.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/strings.xml)

View File

@@ -124,245 +124,3 @@ GitHub have been hopeless. In case it ever becomes available, I want to know imm
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
~
```
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
## Node-RED
You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples:
<details>
<summary>Example: Send a message (click to expand)</summary>
```
[
{
"id": "c956e688cc74ad8e",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 590,
"y": 3160,
"wires":
[
[]
]
},
{
"id": "32ee1eade51fae50",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = \"Something happened\";\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 3160,
"wires":
[
[
"c956e688cc74ad8e"
]
]
},
{
"id": "b287e59cd2311815",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 330,
"y": 3160,
"wires":
[
[
"32ee1eade51fae50"
]
]
}
]
```
</details>
![Node red message flow](static/img/nodered-message.png)
<details>
<summary>Example: Send a picture (click to expand)</summary>
```
[
{
"id": "d135a13eadeb9d6d",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "Download image",
"method": "GET",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 490,
"y": 3320,
"wires":
[
[
"6e75bc41d2ec4a03"
]
]
},
{
"id": "6e75bc41d2ec4a03",
"type": "function",
"z": "fabdd7a3.4045a",
"name": "data",
"func": "msg.payload = msg.payload;\nmsg.headers = {};\nmsg.headers['tags'] = 'house';\nmsg.headers['X-Title'] = 'Home Assistant - Picture';\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 650,
"y": 3320,
"wires":
[
[
"eb160615b6ceda98"
]
]
},
{
"id": "eb160615b6ceda98",
"type": "http request",
"z": "fabdd7a3.4045a",
"name": "ntfy.sh",
"method": "PUT",
"ret": "bin",
"paytoqs": "ignore",
"url": "https://ntfy.sh/mytopic",
"tls": "",
"persist": false,
"proxy": "",
"authType": "",
"senderr": false,
"credentials":
{
"user": "",
"password": ""
},
"x": 770,
"y": 3320,
"wires":
[
[]
]
},
{
"id": "5b8dbf15c8a7a3a5",
"type": "inject",
"z": "fabdd7a3.4045a",
"name": "Manual start",
"props":
[
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "20",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 310,
"y": 3320,
"wires":
[
[
"d135a13eadeb9d6d"
]
]
}
]
```
</details>
![Node red picture flow](static/img/nodered-picture.png)
## Gatus service health check
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
```
alerting:
custom:
url: "https://ntfy.sh"
method: "POST"
body: |
{
"topic": "mytopic",
"message": "[ENDPOINT_NAME] - [ALERT_DESCRIPTION]",
"title": "Gatus",
"tags": ["[ALERT_TRIGGERED_OR_RESOLVED]"],
"priority": 3
}
default-alert:
enabled: true
description: "health check failed"
send-on-resolved: true
failure-threshold: 3
success-threshold: 3
placeholders:
ALERT_TRIGGERED_OR_RESOLVED:
TRIGGERED: "warning"
RESOLVED: "white_check_mark"
```

View File

@@ -26,29 +26,23 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.20.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.20.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.tar.gz
tar zxvf ntfy_1.20.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.20.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.tar.gz
tar zxvf ntfy_1.20.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.20.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
## Debian/Ubuntu repository
@@ -94,7 +88,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -102,7 +96,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -110,7 +104,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -120,21 +114,21 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.20.0/ntfy_1.20.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.1/ntfy_1.17.1_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -152,6 +146,7 @@ cd ntfysh-bin
makepkg -si
```
## Docker
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
straight forward to use.
@@ -186,24 +181,6 @@ docker run \
serve
```
Using docker-compose:
```yaml
version: "2.1"
services:
ntfy:
image: binwiederhier/ntfy
container_name: ntfy
command:
- serve
volumes:
- /var/cache/ntfy:/var/cache/ntfy
- /etc/ntfy:/etc/ntfy
ports:
- 80:80
restart: unless-stopped
```
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
```
FROM binwiederhier/ntfy
@@ -211,3 +188,13 @@ COPY server.yml /etc/ntfy/server.yml
ENTRYPOINT ["ntfy", "serve"]
```
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
## Go
To install via Go, simply run:
```bash
go install heckel.io/ntfy@latest
```
!!! info
Please [let me know](https://github.com/binwiederhier/ntfy/issues) if there are any issues with this installation
method. The SQLite bindings require CGO and it works for me, but I have the feeling it may not work for everyone.

View File

@@ -36,11 +36,6 @@ Here's an example showing how to publish a simple message using a POST request:
strings.NewReader("Backup successful 😀"))
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
@@ -122,16 +117,6 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/phil_alerts"
$headers = @{ Title="Unauthorized access detected"
Priority="urgent"
Tags="warning,skull" }
$body = "Remote access to phils-laptop detected. Act right away."
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
@@ -206,14 +191,6 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/controversial"
$headers = @{ Title="Dogs are better than cats" }
$body = "Oh my ..."
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/controversial",
@@ -245,13 +222,13 @@ notification sounds and vibration patterns on your phone to map to these priorit
The following priorities exist:
| Priority | Icon | ID | Name | Description |
|----------------------|--------------------------------------------|-----|----------------|--------------------------------------------------------------------------------------------------------|
| Max priority | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
| High priority | ![min priority](static/img/priority-4.svg) | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
| Low priority | ![min priority](static/img/priority-2.svg) | `2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
| Min priority | ![min priority](static/img/priority-1.svg) | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
| Priority | Icon | ID | Name | Description |
|---|---|---|---|---|
| Max priority | ![min priority](static/img/priority-5.svg) | `5` | `max`/`urgent` | Really long vibration bursts, default notification sound with a pop-over notification. |
| High priority | ![min priority](static/img/priority-4.svg) | `4` | `high` | Long vibration burst, default notification sound with a pop-over notification. |
| **Default priority** | *(none)* | `3` | `default` | Short default vibration and sound. Default notification behavior. |
| Low priority | ![min priority](static/img/priority-2.svg) |`2` | `low` | No vibration or sound. Notification will not visibly show up until notification drawer is pulled down. |
| Min priority | ![min priority](static/img/priority-1.svg) | `1` | `min` | No vibration or sound. The notification will be under the fold in "Other notifications". |
You can set the priority with the header `X-Priority` (or any of its aliases: `Priority`, `prio`, or `p`).
@@ -294,14 +271,6 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/phil_alerts"
$headers = @{ Priority="5" }
$body = "An urgent message"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/phil_alerts",
@@ -413,14 +382,6 @@ them with a comma, e.g. `tag1,tag2,tag3`.
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/backups"
$headers = @{ Tags="warning,mailsrv13,daily-backup" }
$body = "Backup of mailsrv13 failed"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/backups",
@@ -503,14 +464,6 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/hello"
$headers = @{ At="tomorrow, 10am" }
$body = "Good morning"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/hello",
@@ -546,7 +499,7 @@ Here are a few examples (assuming today's date is **12/10/2021, 9am, Eastern Tim
</td>
</tr></table>
## Webhooks (publish via GET)
## Webhooks (Send via GET)
In addition to using PUT/POST, you can also send to topics via simple HTTP GET requests. This makes it easy to use
a ntfy topic as a [webhook](https://en.wikipedia.org/wiki/Webhook), or if your client has limited HTTP support (e.g.
like the [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid) Android app).
@@ -585,11 +538,6 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo
http.Get("https://ntfy.sh/mywebhook/trigger")
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger"
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/trigger")
@@ -634,11 +582,6 @@ Here's an example with a custom message, tags and a priority:
http.Get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
```
=== "Python"
``` python
requests.get("https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull")
@@ -649,159 +592,6 @@ Here's an example with a custom message, tags and a priority:
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
## Publish as JSON
For some integrations with other tools (e.g. [Jellyfin](https://jellyfin.org/), [overseerr](https://overseerr.dev/)),
adding custom headers to HTTP requests may be tricky or impossible, so ntfy also allows publishing the entire message
as JSON in the request body.
To publish as JSON, simple PUT/POST the JSON object directly to the ntfy root URL. The message format is described below
the example.
!!! info
To publish as JSON, you must **PUT/POST to the ntfy root URL**, not to the topic URL. Be sure to check that you're
POST-ing to `https://ntfy.sh/` (correct), and not to `https://ntfy.sh/mytopic` (incorrect).
Here's an example using most supported parameters. Check the table below for a complete list. The `topic` parameter
is the only required one:
=== "Command line (curl)"
```
curl ntfy.sh \
-d '{
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
}'
```
=== "HTTP"
``` http
POST / HTTP/1.1
Host: ntfy.sh
{
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
}
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.sh', {
method: 'POST',
body: JSON.stringify({
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
})
})
```
=== "Go"
``` go
// You should probably use json.Marshal() instead and make a proper struct,
// or even just use req.Header.Set() like in the other examples, but for the
// sake of the example, this is easier.
body := `{
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
}`
req, _ := http.NewRequest("POST", "https://ntfy.sh/", strings.NewReader(body))
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh"
$body = @{
"topic"="powershell"
"title"="Low disk space alert"
"message"="Disk space is low at 5.1 GB"
"priority"=4
"attach"="https://filesrv.lan/space.jpg"
"filename"="diskspace.jpg"
"tags"=@("warning","cd")
"click"= "https://homecamera.lan/xasds1h2xsSsa/"
} | ConvertTo-Json
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/",
data=json.dumps({
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
})
)
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json",
'content' => json_encode([
"topic": "mytopic",
"message": "Disk space is low at 5.1 GB",
"title": "Low disk space alert",
"tags": ["warning","cd"],
"priority": 4,
"attach": "https://filesrv.lan/space.jpg",
"filename": "diskspace.jpg",
"click": "https://homecamera.lan/xasds1h2xsSsa/"
])
]
]));
```
The JSON message format closely mirrors the format of the message you can consume when you [subscribe via the API](subscribe/api.md)
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields:
| Field | Required | Type | Example | Description |
|------------|----------|----------------------------------|--------------------------------|-----------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
## Click action
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
@@ -849,14 +639,6 @@ Here's an example that will open Reddit when the notification is clicked:
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/reddit_alerts"
$headers = @{ Click="https://www.reddit.com/message/messages" }
$body = "New messages on Reddit"
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/reddit_alerts",
@@ -974,13 +756,7 @@ This could be a Dropbox link, a file from social media, or any other publicly av
externally hosted, the expiration or size limits from above do not apply here.
To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
to specify the attachment URL. It can be any type of file.
ntfy will automatically try to derive the file name from the URL (e.g `https://example.com/flower.jpg` will yield a
filename `flower.jpg`). To override this filename, you may send the `X-Filename` header or query parameter (or any of its
aliases `Filename`, `File` or `f`).
Here's an example showing how to attach an APK file:
to specify the attachment URL. It can be any type of file. Here's an example showing how to attach an APK file:
=== "Command line (curl)"
```
@@ -1019,13 +795,6 @@ Here's an example showing how to attach an APK file:
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/mydownloads"
$headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing
```
=== "Python"
``` python
requests.put("https://ntfy.sh/mydownloads",
@@ -1115,17 +884,6 @@ that, your IP address appears in the e-mail body. This is to prevent abuse.
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/alerts"
$headers = @{ Title"="Low disk space alert"
Priority="high"
Tags="warning,skull,backup-host,ssh-login")
Email="phil@example.com" }
$body = "Unknown login from 5.31.23.83 to backups.example.com"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/alerts",
@@ -1236,14 +994,6 @@ Here's a simple example:
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.example.com/mysecrets"
$headers = @{ Authorization="Basic cGhpbDpteXBhc3M=" }
$body = "Look ma, with auth"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
@@ -1319,14 +1069,6 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/mytopic"
$headers = @{ Cache="no" }
$body = "This message won't be stored server-side"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",
@@ -1399,14 +1141,6 @@ to `no`. This will instruct the server not to forward messages to Firebase.
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
$uri = "https://ntfy.sh/mytopic"
$headers = @{ Firebase="no" }
$body = "This message won't be forwarded to FCM"
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
```
=== "Python"
``` python
requests.post("https://ntfy.sh/mytopic",

View File

@@ -2,124 +2,28 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
<!--
## ntfy Android app v1.11.0 (UNRELEASED)
**Features:**
* Download attachments to cache folder ([#181](https://github.com/binwiederhier/ntfy/issues/181))
* Regularly delete attachments for deleted notifications ([#142](https://github.com/binwiederhier/ntfy/issues/142))
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
**Bugs:**
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
* Refresh preferences screen after settings import (#183, thanks to [@cmeis](https://github.com/cmeis) for reporting)
* Add priority strings to strings.xml to make it translatable (#192, thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
**Translations:**
* English language improvements (thanks to [@comradekingu](https://github.com/comradekingu))
* Bulgarian (thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
* Chinese/Simplified (thanks to [@poi](https://hosted.weblate.org/user/poi) and [@PeterCxy](https://hosted.weblate.org/user/PeterCxy))
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony))
* French (thanks to [@Kusoneko](https://kusoneko.moe/) and [@mlcsthor](https://hosted.weblate.org/user/mlcsthor/))
* German (thanks to [@cmeis](https://github.com/cmeis))
* Italian (thanks to [@theTranslator](https://hosted.weblate.org/user/theTranslator/))
* Indonesian (thanks to [@linerly](https://hosted.weblate.org/user/linerly/))
* Norwegian (*incomplete*, thanks to [@comradekingu](https://github.com/comradekingu))
* Portuguese/Brazil (thanks to [@LW](https://hosted.weblate.org/user/LW/))
* Spanish (thanks to [@rogeliodh](https://github.com/rogeliodh))
* Turkish (thanks to [@ersen](https://ersen.moe/))
**Thanks:**
* Many thanks to [@cmeis](https://github.com/cmeis), [@Fallenbagel](https://github.com/Fallenbagel), [@Joeharrison94](https://github.com/Joeharrison94),
and [@rogeliodh](https://github.com/rogeliodh) for input on the new attachment logic, and for testing the release
-->
## ntfy server v1.20.0
Released Apr 6, 2022
**Features:**:
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
**Bugs:**
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@RasHas](https://github.com/RasHas))
**Documentation:**
* Added docker-compose example to [install instructions](install.md#docker) ([#194](https://github.com/binwiederhier/ntfy/pull/194), thanks to [@RasHas](https://github.com/RasHas))
**Integrations:**
* [Apprise](https://github.com/caronc/apprise) has added integration into ntfy ([#99](https://github.com/binwiederhier/ntfy/issues/99), [apprise#524](https://github.com/caronc/apprise/pull/524),
thanks to [@particledecay](https://github.com/particledecay) and [@caronc](https://github.com/caronc) for their fantastic work)
## ntfy server v1.19.0
Released Mar 30, 2022
**Bugs:**
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
* Do not allow comma in topic name in publish via GET endpoint (no ticket)
* Add "Access-Control-Allow-Origin: *" for attachments (no ticket, thanks to @FrameXX)
* Make pruning run again in web app ([#186](https://github.com/binwiederhier/ntfy/issues/186))
* Added missing params `delay` and `email` to publish as JSON body (no ticket)
**Documentation:**
* Improved [e-mail publishing](config.md#e-mail-publishing) documentation
## ntfy server v1.18.1
Released Mar 21, 2022
_This release ships no features or bug fixes. It's merely a documentation update._
**Documentation:**
* Overhaul of [developer documentation](https://ntfy.sh/docs/develop/)
* PowerShell examples for [publish documentation](https://ntfy.sh/docs/publish/) ([#138](https://github.com/binwiederhier/ntfy/issues/138), thanks to [@Joeharrison94](https://github.com/Joeharrison94))
* Additional examples for [NodeRED, Gatus, Sonarr, Radarr, ...](https://ntfy.sh/docs/examples/) (thanks to [@nickexyz](https://github.com/nickexyz))
* Fixes in developer instructions (thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
## ntfy Android app v1.10.0
Released Mar 21, 2022
**Features:**
* Support for UnifiedPush 2.0 specification (bytes messages, [#130](https://github.com/binwiederhier/ntfy/issues/130))
* Export/import settings and subscriptions ([#115](https://github.com/binwiederhier/ntfy/issues/115), thanks [@cmeis](https://github.com/cmeis) for reporting)
* Open "Click" link when tapping notification ([#110](https://github.com/binwiederhier/ntfy/issues/110), thanks [@cmeis](https://github.com/cmeis) for reporting)
* JSON stream deprecation banner ([#164](https://github.com/binwiederhier/ntfy/issues/164))
**Bug fixes:**
* Display locale-specific times, with AM/PM or 24h format ([#140](https://github.com/binwiederhier/ntfy/issues/140), thanks [@hl2guide](https://github.com/hl2guide) for reporting)
## ntfy server v1.18.0
Released Mar 16, 2022
**Features:**
* [Publish messages as JSON](https://ntfy.sh/docs/publish/#publish-as-json) ([#133](https://github.com/binwiederhier/ntfy/issues/133),
thanks [@cmeis](https://github.com/cmeis) for reporting, thanks to [@Joeharrison94](https://github.com/Joeharrison94) and
[@Fallenbagel](https://github.com/Fallenbagel) for testing)
Released XXXXXXXXXXXXXXX
**Bug fixes:**
* rpm: do not overwrite server.yaml on package upgrade ([#166](https://github.com/binwiederhier/ntfy/issues/166), thanks [@waclaw66](https://github.com/waclaw66) for reporting)
* Typo in [ntfy.sh/announcements](https://ntfy.sh/announcements) topic ([#170](https://github.com/binwiederhier/ntfy/pull/170), thanks to [@sandebert](https://github.com/sandebert))
* Readme image URL fixes ([#156](https://github.com/binwiederhier/ntfy/pull/156), thanks to [@ChaseCares](https://github.com/ChaseCares))
* rpm: do not overwrite server.yaml on package upgrade (#166, thanks @waclaw66 for reporting)
**Deprecations:**
* Removed the ability to run server as `ntfy` (as opposed to `ntfy serve`) as per [deprecation](deprecations.md)
* Removed the ability to run server as `ntfy serve` as per [deprecation](https://ntfy.sh/docs/deprecations)
## ntfy Android app v1.10.0
Released XXXXXXXXXXXXXXX
**Features:**
* Support for UnifiedPush 2.0 specification (bytes messages, #130)
* Export/import settings and subscriptions (#115, thanks @cmeis for reporting)
**Bug fixes:**
* Display locale-specific times, with AM/PM or 24h format (#140, thanks @hl2guide for reporting)
## ntfy server v1.17.1
Released Mar 12, 2022

View File

@@ -8,10 +8,6 @@
width: unset !important;
}
.md-sidebar {
width: 12.5rem !important;
}
.md-typeset h4 {
font-weight: 500 !important;
margin: 0 !important;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

39
go.mod
View File

@@ -4,48 +4,51 @@ go 1.17
require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.21.0 // indirect
cloud.google.com/go/storage v1.19.0 // indirect
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v1.1.0 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/gabriel-vasile/mimetype v1.4.0
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.12
github.com/gorilla/websocket v1.4.2
github.com/mattn/go-sqlite3 v1.14.11
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.4.0
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
github.com/tidwall/gjson v1.14.0
github.com/urfave/cli/v2 v2.3.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
google.golang.org/api v0.74.0
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.67.0
gopkg.in/yaml.v2 v2.4.0
)
require (
cloud.google.com/go v0.100.2 // indirect
cloud.google.com/go/compute v1.5.0 // indirect
cloud.google.com/go/iam v0.3.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
cloud.google.com/go/compute v1.2.0 // indirect
cloud.google.com/go/iam v0.1.1 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/googleapis/gax-go/v2 v2.2.0 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
google.golang.org/grpc v1.45.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

103
go.sum
View File

@@ -36,17 +36,14 @@ cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUM
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.2.0 h1:EKki8sSdvDU0OO9mAXGwPXOTOgPz2l08R0/IutDH11I=
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0 h1:b1zWmYuuHz7gO9kDcM/EpHGr06UgsYNRpNJzI2kFiLM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/iam v0.1.1 h1:4CapQyNFjiksks1/x7jsvsygFPhihslYk5GptIrlX68=
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -56,19 +53,16 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.21.0 h1:HwnT2u2D309SFDHQII6m18HlrCi3jAXhUMTLOWXYH14=
cloud.google.com/go/storage v1.21.0/go.mod h1:XmRlxkgPjlBONznT2dDUU/5XlpU2OjMnKuqnZI01LAA=
cloud.google.com/go/storage v1.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q=
cloud.google.com/go/storage v1.19.0/go.mod h1:6rgiTRjOqI/Zd9YKimub5TIB4d+p3LH33V3ZE1DMuUM=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -87,6 +81,7 @@ github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -187,11 +182,10 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -205,8 +199,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -217,8 +211,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -227,8 +223,14 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -247,11 +249,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s=
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -323,13 +322,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -346,10 +340,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -413,15 +405,8 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12 h1:QyVthZKMsyaQwBTJE04jdNN0Pp5Fn9Qga0mrgxyERQM=
golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -438,8 +423,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -529,15 +514,10 @@ google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUb
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
google.golang.org/api v0.65.0/go.mod h1:ArYhxgGadlWmqO1IqVujw6Cs8IdD33bTmzKo2Sh+cbg=
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
google.golang.org/api v0.67.0 h1:lYaaLa+x3VVUhtosaK9xihwQ9H9KRa557REHwwZ2orM=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/api v0.69.0/go.mod h1:boanBiw+h5c3s+tBPgEzLDRHfFLWV0qXxRHz3ws7C80=
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.73.0 h1:O9bThUh35K1rvUrQwTUQ1eqLC/IYyzUpWavYIO2EXvo=
google.golang.org/api v0.73.0/go.mod h1:lbd/q6BRFJbdpV6OUCXstVeiI5mL/d3/WifG7iNKnjI=
google.golang.org/api v0.74.0 h1:ExR2D+5TYIrMphWgs5JCgwRhEDlPDXXrLwHHMgPHTXE=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -609,22 +589,14 @@ google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220211171837-173942840c17/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220216160803-4663080d8bc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2 h1:3n0D2NdPGm0g0wrVJzXJWW5CBOoqgGBkDX9cRMJHZAY=
google.golang.org/genproto v0.0.0-20220322021311-435b647f9ef2/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf h1:JTjwKJX9erVpsw17w+OIPP7iAgEkN/r8urhWSunEDTs=
google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f h1:w9Sx4FBkwsN0jMZz8E42tMdmhZ5b2Z/vFx2LKAxxI9o=
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -651,9 +623,8 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.45.0 h1:NEpgUqV3Z+ZjkqMsxMg11IaDrXY4RY6CQukSGK0uI1M=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -667,16 +638,14 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=

View File

@@ -19,11 +19,10 @@ func main() {
Try 'ntfy COMMAND --help' or https://ntfy.sh/docs/ for more information.
To report a bug, open an issue on GitHub: https://github.com/binwiederhier/ntfy/issues.
If you want to chat, simply join the Discord server (https://discord.gg/cT7ECsZj9w), or
the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
If you want to chat, simply join the Discord server: https://discord.gg/cT7ECsZj9w.
ntfy %s (%s), runtime %s, built at %s
Copyright (C) 2022 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
Copyright (C) 2021 Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
`, version, commit[:7], runtime.Version(), date)
app := cmd.New()

View File

@@ -1,3 +1,9 @@
# The documentation uses 'mkdocs', which is written in Python
# See https://github.com/squidfunk/mkdocs-material/issues/2030
jinja2>=2.11.1
# mkdocs
mkdocs
mkdocs-material
mkdocs-minify-plugin

View File

@@ -10,7 +10,7 @@ if [ -z "$1" ]; then
echo "Syntax: $0 FILE.(js|json|md)"
echo "Example:"
echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/web/src/app/emojis.js"
echo " $0 $ROOTDIR/server/static/js/emoji.js"
echo " $0 $ROOTDIR/docs/emojis.md"
exit 1
fi
@@ -19,7 +19,7 @@ if [[ "$1" == *.js ]]; then
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
export const rawEmojis = " > "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji, aliases: .aliases, tags: .tags, category: .category, description: .description, unicode_version: .unicode_version})' >> "$1"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
elif [[ "$1" == *.md ]]; then
echo "# Emoji reference

View File

@@ -21,7 +21,8 @@ const (
// - total topic limit: max number of topics overall
// - various attachment limits
const (
DefaultMessageLengthLimit = 4096 // Bytes
DefaultMessageLengthLimit = 4096 // Bytes
MaxMessageLengthLimit = 16 * 1024 * 1024 // 16 MB, sanity size
DefaultTotalTopicLimit = 15000
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB

View File

@@ -34,15 +34,14 @@ var (
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", ""}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}

View File

@@ -355,7 +355,7 @@ func (c *messageCache) Prune(olderThan time.Time) error {
return err
}
func (c *messageCache) AttachmentBytesUsed(owner string) (int64, error) {
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
if err != nil {
return 0, err

View File

@@ -337,11 +337,11 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
size, err := c.AttachmentBytesUsed("1.2.3.4")
size, err := c.AttachmentsSize("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(30000), size)
size, err = c.AttachmentBytesUsed("5.6.7.8")
size, err = c.AttachmentsSize("5.6.7.8")
require.Nil(t, err)
require.Equal(t, int64(0), size)

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"github.com/tidwall/gjson"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
@@ -55,18 +56,17 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
var (
// If changed, don't forget to update Android App and auth_sqlite.go
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
extTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
webConfigPath = "/config.js"
userStatsPath = "/user/stats"
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
@@ -270,8 +270,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleEmpty(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.handleWebConfig(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
@@ -280,8 +278,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.handleFile)(w, r, v)
} else if r.Method == http.MethodOptions {
return s.handleOptions(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
@@ -296,7 +292,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || extTopicPathRegex.MatchString(r.URL.Path)) {
return s.handleTopic(w, r)
}
return errHTTPNotFound
@@ -354,19 +350,6 @@ var config = {
return err
}
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
stats, err := v.Stats()
if err != nil {
return err
}
w.Header().Set("Content-Type", "text/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(stats); err != nil {
return err
}
return nil
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
@@ -396,7 +379,6 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
return errHTTPTooManyRequestsAttachmentBandwidthLimit
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
f, err := os.Open(file)
if err != nil {
return err
@@ -411,16 +393,16 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err != nil {
return err
}
body, err := util.Peek(r.Body, s.config.MessageLimit)
body, err := util.Peak(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
cache, firebase, email, template, unifiedpush, err := s.parsePublishParams(r, v, m)
if err != nil {
return err
}
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
return err
}
if m.Message == "" {
@@ -462,7 +444,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return nil
}
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, template string, unifiedpush bool, err error) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
@@ -477,7 +459,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
}
if attach != "" {
if !attachURLRegex.MatchString(attach) {
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
@@ -496,11 +478,11 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" {
if err := v.EmailAllowed(); err != nil {
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
return false, false, "", "", false, errHTTPTooManyRequestsLimitEmails
}
}
if s.mailer == nil && email != "" {
return false, false, "", false, errHTTPBadRequestEmailDisabled
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
@@ -508,7 +490,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
}
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if err != nil {
return false, false, "", false, errHTTPBadRequestPriorityInvalid
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
}
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
if tagsStr != "" {
@@ -520,27 +502,33 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", false, errHTTPBadRequestDelayNoCache
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", false, errHTTPBadRequestDelayCannotParse
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooSmall
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooLarge
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
template = readParam(r, "x-template", "template", "tpl")
if template != "" {
if template != "json" {
return false, false, "", "", false, errors.New("invalid template")
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
unifiedpush = true
}
return cache, firebase, email, unifiedpush, nil
return cache, firebase, email, template, unifiedpush, nil
}
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
@@ -555,35 +543,56 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, template string, unifiedpush bool) error {
if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 2
return s.handleBodyAsTextMessage(m, body, template) // Case 2
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 4
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
return s.handleBodyAsTextMessage(m, body, template) // Case 4
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
}
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedReadCloser) error {
if utf8.Valid(body.PeekedBytes) {
m.Message = string(body.PeekedBytes) // Do not trim
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
if utf8.Valid(body.PeakedBytes) {
m.Message = string(body.PeakedBytes) // Do not trim
} else {
m.Message = base64.StdEncoding.EncodeToString(body.PeekedBytes)
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
m.Encoding = encodingBase64
}
return nil
}
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
if !utf8.Valid(body.PeekedBytes) {
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser, template string) error {
if !utf8.Valid(body.PeakedBytes) {
return errHTTPBadRequestMessageNotUTF8
}
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!)
peakedBody := strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required
if template == "json" && gjson.Valid(peakedBody) {
r := regexp.MustCompile(`\${([^}]+)}`)
matches := r.FindAllStringSubmatch(m.Message, -1)
for _, v := range matches {
query := v[1]
result := gjson.Get(peakedBody, query)
if result.Exists() {
m.Message = strings.ReplaceAll(m.Message, fmt.Sprintf("${%s}", query), result.String())
}
}
matches = r.FindAllStringSubmatch(m.Title, -1)
for _, v := range matches {
query := v[1]
result := gjson.Get(peakedBody, query)
if result.Exists() {
m.Title = strings.ReplaceAll(m.Title, fmt.Sprintf("${%s}", query), result.String())
}
}
} else {
m.Message = peakedBody
}
}
if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
@@ -591,21 +600,22 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
return nil
}
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
return errHTTPBadRequestAttachmentsDisallowed
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
}
visitorStats, err := v.Stats()
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
if err != nil {
return err
}
remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize
contentLengthStr := r.Header.Get("Content-Length")
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPEntityTooLargeAttachmentTooLarge
if err == nil && (contentLength > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) {
return errHTTPBadRequestAttachmentTooLarge
}
}
if m.Attachment == nil {
@@ -614,7 +624,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
var ext string
m.Attachment.Owner = v.ip // Important for attachment rate limiting
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name)
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
if m.Attachment.Name == "" {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
@@ -622,9 +632,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
if m.Message == "" {
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
}
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize))
if err == util.ErrLimitReached {
return errHTTPEntityTooLargeAttachmentTooLarge
return errHTTPBadRequestAttachmentTooLarge
} else if err != nil {
return err
}
@@ -1111,55 +1121,6 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
}
}
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
defer r.Body.Close()
var m publishMessage
if err := json.NewDecoder(body).Decode(&m); err != nil {
return errHTTPBadRequestJSONInvalid
}
if !topicRegex.MatchString(m.Topic) {
return errHTTPBadRequestTopicInvalid
}
if m.Message == "" {
m.Message = emptyMessageBody
}
r.URL.Path = "/" + m.Topic
r.Body = io.NopCloser(strings.NewReader(m.Message))
if m.Title != "" {
r.Header.Set("X-Title", m.Title)
}
if m.Priority != 0 {
r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority))
}
if m.Tags != nil && len(m.Tags) > 0 {
r.Header.Set("X-Tags", strings.Join(m.Tags, ","))
}
if m.Attach != "" {
r.Header.Set("X-Attach", m.Attach)
}
if m.Filename != "" {
r.Header.Set("X-Filename", m.Filename)
}
if m.Click != "" {
r.Header.Set("X-Click", m.Click)
}
if m.Email != "" {
r.Header.Set("X-Email", m.Email)
}
if m.Delay != "" {
r.Header.Set("X-Delay", m.Delay)
}
return next(w, r, v)
}
}
func (s *Server) authWrite(next handleFunc) handleFunc {
return s.withAuth(next, auth.PermissionWrite)
}
@@ -1231,7 +1192,7 @@ func (s *Server) visitor(r *http.Request) *visitor {
}
v, exists := s.visitors[ip]
if !exists {
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
s.visitors[ip] = newVisitor(s.config, ip)
return s.visitors[ip]
}
v.Keepalive()

View File

@@ -203,14 +203,6 @@ func TestServer_PublishPriority(t *testing.T) {
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
}
func TestServer_PublishGETOnlyOneTopic(t *testing.T) {
// This tests a bug that allowed publishing topics with a comma in the name (no ticket)
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "GET", "/mytopic,mytopic2/publish?m=hi", "", nil)
require.Equal(t, 404, response.Code)
}
func TestServer_PublishNoCache(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
@@ -714,12 +706,6 @@ func (t *testMailer) Send(from, to string, m *message) error {
return nil
}
func (t *testMailer) Count() int {
t.mu.Lock()
defer t.mu.Unlock()
return t.count
}
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
for i := 0; i < 60; i++ {
@@ -876,48 +862,6 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
require.Equal(t, "this is a unifiedpush text message", m.Message)
}
func TestServer_PublishAsJSON(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic","message":"A message","title":"a title\nwith lines","tags":["tag1","tag 2"],` +
`"not-a-thing":"ok", "attach":"http://google.com","filename":"google.pdf", "click":"http://ntfy.sh","priority":4,` +
`"delay":"30min"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "A message", m.Message)
require.Equal(t, "a title\nwith lines", m.Title)
require.Equal(t, []string{"tag1", "tag 2"}, m.Tags)
require.Equal(t, "http://google.com", m.Attachment.URL)
require.Equal(t, "google.pdf", m.Attachment.Name)
require.Equal(t, "http://ntfy.sh", m.Click)
require.Equal(t, 4, m.Priority)
require.True(t, m.Time > time.Now().Unix()+29*60)
require.True(t, m.Time < time.Now().Unix()+31*60)
}
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
mailer := &testMailer{}
s := newTestServer(t, newTestConfig(t))
s.mailer = mailer
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "A message", m.Message)
require.Equal(t, 1, mailer.Count())
}
func TestServer_PublishAsJSON_Invalid(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
body := `{"topic":"mytopic",INVALID`
response := request(t, s, "PUT", "/", body, nil)
require.Equal(t, 400, response.Code)
}
func TestServer_PublishAttachment(t *testing.T) {
content := util.RandomString(5000) // > 4096
s := newTestServer(t, newTestConfig(t))
@@ -938,7 +882,7 @@ func TestServer_PublishAttachment(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
require.Nil(t, err)
require.Equal(t, int64(5000), size)
}
@@ -967,7 +911,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
require.Equal(t, content, response.Body.String())
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
require.Nil(t, err)
require.Equal(t, int64(21), size)
}
@@ -987,7 +931,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
require.Equal(t, "", msg.Attachment.Owner)
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
require.Nil(t, err)
require.Equal(t, int64(0), size)
}
@@ -1024,9 +968,9 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
"Content-Length": "20000000",
})
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
}
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
@@ -1036,9 +980,9 @@ func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.
s := newTestServer(t, c)
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
}
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
@@ -1068,9 +1012,9 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
content := util.RandomString(5001) // 5000+5001 > , see below
response = request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, response.Code)
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40012, err.Code)
}
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
@@ -1144,32 +1088,8 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
// And a failed one
response := request(t, s, "PUT", "/mytopic", content, nil)
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, response.Code)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishAttachmentUserStats(t *testing.T) {
content := util.RandomString(4999) // > 4096
c := newTestConfig(t)
c.AttachmentFileSizeLimit = 5000
c.VisitorAttachmentTotalSizeLimit = 6000
s := newTestServer(t, c)
// Upload one attachment
response := request(t, s, "PUT", "/mytopic", content, nil)
msg := toMessage(t, response.Body.String())
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
// User stats
response = request(t, s, "GET", "/user/stats", "", nil)
require.Equal(t, 200, response.Code)
var stats visitorStats
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
require.Equal(t, 400, response.Code)
require.Equal(t, 40012, err.Code)
}
func newTestConfig(t *testing.T) *Config {

View File

@@ -42,20 +42,6 @@ type attachment struct {
Owner string `json:"-"` // IP address of uploader, used for rate limiting
}
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
Tags []string `json:"tags"`
Click string `json:"click"`
Attach string `json:"attach"`
Filename string `json:"filename"`
Email string `json:"email"`
Delay string `json:"delay"`
}
// messageEncoder is a function that knows how to encode a message
type messageEncoder func(msg *message) (string, error)

View File

@@ -22,7 +22,6 @@ var (
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
type visitor struct {
config *Config
messageCache *messageCache
ip string
requests *rate.Limiter
emails *rate.Limiter
@@ -32,17 +31,9 @@ type visitor struct {
mu sync.Mutex
}
type visitorStats struct {
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
}
func newVisitor(conf *Config, messageCache *messageCache, ip string) *visitor {
func newVisitor(conf *Config, ip string) *visitor {
return &visitor{
config: conf,
messageCache: messageCache,
ip: ip,
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
@@ -100,20 +91,3 @@ func (v *visitor) Stale() bool {
defer v.mu.Unlock()
return time.Since(v.seen) > visitorExpungeAfter
}
func (v *visitor) Stats() (*visitorStats, error) {
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip)
if err != nil {
return nil, err
}
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
if attachmentsBytesRemaining < 0 {
attachmentsBytesRemaining = 0
}
return &visitorStats{
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
}, nil
}

61
util/peak.go Normal file
View File

@@ -0,0 +1,61 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeakedReadCloser struct {
PeakedBytes []byte
LimitReached bool
peaked io.Reader
underlying io.ReadCloser
closed bool
}
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peaked := make([]byte, limit)
read, err := io.ReadFull(underlying, peaked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeakedReadCloser{
PeakedBytes: peaked[:read],
LimitReached: read == limit,
underlying: underlying,
peaked: bytes.NewReader(peaked[:read]),
closed: false,
}, nil
}
// Read reads from the peaked bytes and then from the underlying stream
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peaked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeakedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

View File

@@ -9,11 +9,11 @@ import (
func TestPeak_LimitReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peek(underlying, 5)
peaked, err := Peak(underlying, 5)
if err != nil {
t.Fatal(err)
}
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, true, peaked.LimitReached)
all, err := io.ReadAll(peaked)
@@ -21,13 +21,13 @@ func TestPeak_LimitReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("12345"), peaked.PeekedBytes)
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
require.Equal(t, true, peaked.LimitReached)
}
func TestPeak_LimitNotReached(t *testing.T) {
underlying := io.NopCloser(strings.NewReader("1234567890"))
peaked, err := Peek(underlying, 15)
peaked, err := Peak(underlying, 15)
if err != nil {
t.Fatal(err)
}
@@ -36,12 +36,12 @@ func TestPeak_LimitNotReached(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte("1234567890"), all)
require.Equal(t, []byte("1234567890"), peaked.PeekedBytes)
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
require.Equal(t, false, peaked.LimitReached)
}
func TestPeak_Nil(t *testing.T) {
peaked, err := Peek(nil, 15)
peaked, err := Peak(nil, 15)
if err != nil {
t.Fatal(err)
}
@@ -50,6 +50,6 @@ func TestPeak_Nil(t *testing.T) {
t.Fatal(err)
}
require.Equal(t, []byte(""), all)
require.Equal(t, []byte(""), peaked.PeekedBytes)
require.Equal(t, []byte(""), peaked.PeakedBytes)
require.Equal(t, false, peaked.LimitReached)
}

View File

@@ -1,61 +0,0 @@
package util
import (
"bytes"
"io"
"strings"
)
// PeekedReadCloser is a ReadCloser that allows peeking into a stream and buffering it in memory.
// It can be instantiated using the Peek function. After a stream has been peeked, it can still be fully
// read by reading the PeekedReadCloser. It first drained from the memory buffer, and then from the remaining
// underlying reader.
type PeekedReadCloser struct {
PeekedBytes []byte
LimitReached bool
peeked io.Reader
underlying io.ReadCloser
closed bool
}
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
if underlying == nil {
underlying = io.NopCloser(strings.NewReader(""))
}
peeked := make([]byte, limit)
read, err := io.ReadFull(underlying, peeked)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
return &PeekedReadCloser{
PeekedBytes: peeked[:read],
LimitReached: read == limit,
underlying: underlying,
peeked: bytes.NewReader(peeked[:read]),
closed: false,
}, nil
}
// Read reads from the peeked bytes and then from the underlying stream
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {
return 0, io.EOF
}
n, err = r.peeked.Read(p)
if err == io.EOF {
return r.underlying.Read(p)
} else if err != nil {
return 0, err
}
return
}
// Close closes the underlying stream
func (r *PeekedReadCloser) Close() error {
if r.closed {
return io.EOF
}
r.closed = true
return r.underlying.Close()
}

4367
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,8 @@
"react-router-dom": "^6.2.2",
"react-scripts": "^5.0.0",
"stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2"
"stacktrace-js": "^2.0.2",
"svgo": "^2.8.0"
},
"browserslist": {
"production": [

View File

@@ -16,7 +16,7 @@ html {
}
a, a:visited {
color: #338574;
color: #3a9784;
}
a:hover {
@@ -114,7 +114,7 @@ code {
}
.anchor .anchorLink:hover {
color: #338574;
color: #3a9784;
visibility: visible;
}
@@ -221,7 +221,7 @@ figcaption {
/* Header */
#header {
background: #338574;
background: #3a9784;
height: 130px;
}

View File

@@ -1,13 +1,11 @@
import {
basicAuth,
encodeBase64,
fetchLinesIterator,
maybeWithBasicAuth,
topicShortUrl,
topicUrl,
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince, userStatsUrl
topicUrlJsonPollWithSince
} from "./utils";
import userManager from "./UserManager";
@@ -28,78 +26,25 @@ class Api {
return messages;
}
async publish(baseUrl, topic, message, options) {
async publish(baseUrl, topic, message, title, priority, tags) {
const user = await userManager.get(baseUrl);
console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`);
const url = topicUrl(baseUrl, topic);
console.log(`[Api] Publishing message to ${url}`);
const headers = {};
const body = {
topic: topic,
message: message,
...options
};
const response = await fetch(baseUrl, {
if (title) {
headers["X-Title"] = title;
}
if (priority !== 3) {
headers["X-Priority"] = `${priority}`;
}
if (tags.length > 0) {
headers["X-Tags"] = tags.join(",");
}
await fetch(url, {
method: 'PUT',
body: JSON.stringify(body),
body: message,
headers: maybeWithBasicAuth(headers, user)
});
if (response.status < 200 || response.status > 299) {
throw new Error(`Unexpected response: ${response.status}`);
}
return response;
}
/**
* Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request.
* Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used.
*
* Firefox XHR bug:
* Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error,
* so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the
* correct headers are clearly set. It's quite the odd behavior.
*
* There is an example, and the bug report here:
* - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755
* - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345
*/
publishXHR(url, body, headers, onProgress) {
console.log(`[Api] Publishing message to ${url}`);
const xhr = new XMLHttpRequest();
const send = new Promise(function (resolve, reject) {
xhr.open("PUT", url);
if (body.type) {
xhr.overrideMimeType(body.type);
}
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.addEventListener("progress", onProgress);
xhr.addEventListener('readystatechange', (ev) => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
resolve(xhr.response);
} else if (xhr.readyState === 4) {
// Firefox bug; see description above!
console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText);
let errorText;
try {
const error = JSON.parse(xhr.responseText);
if (error.code && error.error) {
errorText = `Error ${error.code}: ${error.error}`;
}
} catch (e) {
// Nothing
}
xhr.abort();
reject(errorText ?? "An error occurred");
}
})
xhr.send(body);
});
send.abort = () => {
console.log(`[Api] Publish aborted by user`);
xhr.abort();
}
return send;
}
async auth(baseUrl, topic, user) {
@@ -117,18 +62,6 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
async userStats(baseUrl) {
const url = userStatsUrl(baseUrl);
console.log(`[Api] Fetching user stats ${url}`);
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unexpected server response ${response.status}`);
}
const stats = await response.json();
console.log(`[Api] Stats`, stats);
return stats;
}
}
const api = new Api();

View File

@@ -56,4 +56,6 @@ class Poller {
}
const poller = new Poller();
poller.startWorker();
export default poller;

View File

@@ -1,7 +1,7 @@
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
const delayMillis = 25000; // 25 seconds
const delayMillis = 15000; // 15 seconds
const intervalMillis = 1800000; // 30 minutes
class Pruner {
@@ -35,4 +35,6 @@ class Pruner {
}
const pruner = new Pruner();
pruner.startWorker();
export default pruner;

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,6 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
export const expandSecureUrl = (url) => `https://${url}`;
@@ -116,13 +115,6 @@ export const shuffle = (arr) => {
return arr;
}
export const splitNoEmpty = (s, delimiter) => {
return s
.split(delimiter)
.map(x => x.trim())
.filter(x => x !== "");
}
/** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */
export const hashCode = async (s) => {
let hash = 0;

View File

@@ -105,7 +105,7 @@ const SettingsIcons = (props) => {
}
};
const handleSendTestMessage = async () => {
const handleSendTestMessage = () => {
const baseUrl = props.subscription.baseUrl;
const topic = props.subscription.topic;
const tags = shuffle([
@@ -121,29 +121,18 @@ const SettingsIcons = (props) => {
"Titles are optional, did you know that?",
"ntfy is open source, and will always be free. Cool, right?",
"I don't really like apples",
"My favorite TV show is The Wire. You should watch it!",
"You can attach files and URLs to messages too",
"You can delay messages up to 3 days"
"My favorite TV show is The Wire. You should watch it!"
])[0];
const nowSeconds = Math.round(Date.now()/1000);
const message = shuffle([
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`,
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(Date.now())} right now. Is that early or late?`,
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
`Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`Alright then, it's ${formatShortDateTime(Date.now())} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`,
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/annoucements.`,
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
])[0];
try {
await api.publish(baseUrl, topic, message, {
title: title,
priority: priority,
tags: tags
});
} catch (e) {
console.log(`[ActionBar] Error publishing message`, e);
}
api.publish(baseUrl, topic, message, title, priority, tags);
setOpen(false);
}

View File

@@ -17,10 +17,9 @@ import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from
import {expandUrl} from "../app/utils";
import ErrorBoundary from "./ErrorBoundary";
import routes from "./routes";
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
import SendDialog from "./SendDialog";
import Messaging from "./Messaging";
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
// TODO add drag and drop
// TODO races when two tabs are open
// TODO investigate service workers
@@ -59,7 +58,6 @@ const Layout = () => {
const params = useParams();
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
const users = useLiveQuery(() => userManager.all());
const subscriptions = useLiveQuery(() => subscriptionManager.all());
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
@@ -69,7 +67,7 @@ const Layout = () => {
});
useConnectionListeners(subscriptions, users);
useBackgroundProcesses();
useLocalStorageMigration();
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
return (
@@ -86,17 +84,11 @@ const Layout = () => {
mobileDrawerOpen={mobileDrawerOpen}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
onNotificationGranted={setNotificationsGranted}
onPublishMessageClick={() => setSendDialogOpenMode(SendDialog.OPEN_MODE_DEFAULT)}
/>
<Main>
<Toolbar/>
<Outlet context={{ subscriptions, selected }}/>
</Main>
<Messaging
selected={selected}
dialogOpenMode={sendDialogOpenMode}
onDialogOpenModeChange={setSendDialogOpenMode}
/>
</Box>
);
}

View File

@@ -1,38 +0,0 @@
import * as React from "react";
import Box from "@mui/material/Box";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import fileAudio from "../img/file-audio.svg";
import fileApp from "../img/file-app.svg";
const AttachmentIcon = (props) => {
const type = props.type;
let imageFile;
if (!type) {
imageFile = fileDocument;
} else if (type.startsWith('image/')) {
imageFile = fileImage;
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
} else {
imageFile = fileDocument;
}
return (
<Box
component="img"
src={imageFile}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
export default AttachmentIcon;

View File

@@ -1,29 +0,0 @@
import * as React from "react";
import Box from "@mui/material/Box";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingBottom: '8px',
}}>
<DialogContentText component="div" sx={{
margin: '0px',
paddingTop: '12px',
paddingBottom: '4px'
}}>
{props.status}
</DialogContentText>
<DialogActions sx={{paddingRight: 2}}>
{props.children}
</DialogActions>
</Box>
);
};
export default DialogFooter;

View File

@@ -1,169 +0,0 @@
import * as React from 'react';
import {useRef, useState} from 'react';
import Typography from '@mui/material/Typography';
import {rawEmojis} from '../app/emojis';
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import {Close} from "@mui/icons-material";
import Popper from "@mui/material/Popper";
import {splitNoEmpty} from "../app/utils";
// Create emoji list by category and create a search base (string with all search words)
//
// This also filters emojis that are not supported by Desktop Chrome.
// This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported.
const emojisByCategory = {};
const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent);
const maxSupportedVersionForDesktopChrome = 11;
rawEmojis.forEach(emoji => {
if (!emojisByCategory[emoji.category]) {
emojisByCategory[emoji.category] = [];
}
try {
const unicodeVersion = parseFloat(emoji.unicode_version);
const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome;
if (supportedEmoji) {
const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`;
const emojiWithSearchBase = { ...emoji, searchBase: searchBase };
emojisByCategory[emoji.category].push(emojiWithSearchBase);
}
} catch (e) {
// Nothing. Ignore.
}
});
const EmojiPicker = (props) => {
const open = Boolean(props.anchorEl);
const [search, setSearch] = useState("");
const searchRef = useRef(null);
const searchFields = splitNoEmpty(search.toLowerCase(), " ");
const handleSearchClear = () => {
setSearch("");
searchRef.current?.focus();
};
return (
<Popper
open={open}
anchorEl={props.anchorEl}
placement="bottom-start"
sx={{ zIndex: 10005 }}
transition
>
{({ TransitionProps }) => (
<ClickAwayListener onClickAway={props.onClose}>
<Fade {...TransitionProps} timeout={350}>
<Box sx={{
boxShadow: 3,
padding: 2,
paddingRight: 0,
paddingBottom: 1,
width: "380px",
maxHeight: "300px",
backgroundColor: 'background.paper',
overflowY: "scroll"
}}>
<TextField
inputRef={searchRef}
margin="dense"
size="small"
placeholder="Search emoji"
value={search}
onChange={ev => setSearch(ev.target.value)}
type="text"
variant="standard"
fullWidth
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
InputProps={{
endAdornment:
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
<IconButton size="small" onClick={handleSearchClear} edge="end"><Close/></IconButton>
</InputAdornment>
}}
/>
<Box sx={{ display: "flex", flexWrap: "wrap", paddingRight: 0, marginTop: 1 }}>
{Object.keys(emojisByCategory).map(category =>
<Category
key={category}
title={category}
emojis={emojisByCategory[category]}
search={searchFields}
onPick={props.onEmojiPick}
/>
)}
</Box>
</Box>
</Fade>
</ClickAwayListener>
)}
</Popper>
);
};
const Category = (props) => {
const showTitle = props.search.length === 0;
return (
<>
{showTitle &&
<Typography variant="body1" sx={{ width: "100%", marginBottom: 1 }}>
{props.title}
</Typography>
}
{props.emojis.map(emoji =>
<Emoji
key={emoji.aliases[0]}
emoji={emoji}
search={props.search}
onClick={() => props.onPick(emoji.aliases[0])}
/>
)}
</>
);
};
const Emoji = (props) => {
const emoji = props.emoji;
const matches = emojiMatches(emoji, props.search);
return (
<EmojiDiv
onClick={props.onClick}
title={`${emoji.description} (${emoji.aliases[0]})`}
style={{ display: (matches) ? '' : 'none' }}
>
{props.emoji.emoji}
</EmojiDiv>
);
};
const EmojiDiv = styled("div")({
fontSize: "30px",
width: "30px",
height: "30px",
marginTop: "8px",
marginBottom: "8px",
marginRight: "8px",
lineHeight: "30px",
cursor: "pointer",
opacity: 0.85,
"&:hover": {
opacity: 1
}
});
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;
}
export default EmojiPicker;

View File

@@ -1,111 +0,0 @@
import * as React from 'react';
import {useState} from 'react';
import Navigation from "./Navigation";
import {topicUrl} from "../app/utils";
import Paper from "@mui/material/Paper";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import SendIcon from "@mui/icons-material/Send";
import api from "../app/Api";
import SendDialog from "./SendDialog";
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {Portal, Snackbar} from "@mui/material";
const Messaging = (props) => {
const [message, setMessage] = useState("");
const [dialogKey, setDialogKey] = useState(0);
const dialogOpenMode = props.dialogOpenMode;
const subscription = props.selected;
const handleOpenDialogClick = () => {
props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT);
};
const handleSendDialogClose = () => {
props.onDialogOpenModeChange("");
setDialogKey(prev => prev+1);
};
return (
<>
{subscription && <MessageBar
subscription={subscription}
message={message}
onMessageChange={setMessage}
onOpenDialogClick={handleOpenDialogClick}
/>}
<SendDialog
key={`sendDialog${dialogKey}`} // Resets dialog when canceled/closed
openMode={dialogOpenMode}
baseUrl={subscription?.baseUrl ?? window.location.origin}
topic={subscription?.topic ?? ""}
message={message}
onClose={handleSendDialogClose}
onDragEnter={() => props.onDialogOpenModeChange(prev => (prev) ? prev : SendDialog.OPEN_MODE_DRAG)} // Only update if not already open
onResetOpenMode={() => props.onDialogOpenModeChange(SendDialog.OPEN_MODE_DEFAULT)}
/>
</>
);
}
const MessageBar = (props) => {
const subscription = props.subscription;
const [snackOpen, setSnackOpen] = useState(false);
const handleSendClick = async () => {
try {
await api.publish(subscription.baseUrl, subscription.topic, props.message);
} catch (e) {
console.log(`[MessageBar] Error publishing message`, e);
setSnackOpen(true);
}
props.onMessageChange("");
};
return (
<Paper
elevation={3}
sx={{
display: "flex",
position: 'fixed',
bottom: 0,
right: 0,
padding: 2,
width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` },
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
}}
>
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick}>
<KeyboardArrowUpIcon/>
</IconButton>
<TextField
autoFocus
margin="dense"
placeholder="Type a message here"
type="text"
fullWidth
variant="standard"
value={props.message}
onChange={ev => props.onMessageChange(ev.target.value)}
onKeyPress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
handleSendClick();
}
}}
/>
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick}>
<SendIcon/>
</IconButton>
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message="Error publishing message"
/>
</Portal>
</Paper>
);
};
export default Messaging;

View File

@@ -19,7 +19,7 @@ import routes from "./routes";
import {ConnectionState} from "../app/Connection";
import {useLocation, useNavigate} from "react-router-dom";
import subscriptionManager from "../app/SubscriptionManager";
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
import {ChatBubble, NotificationsOffOutlined} from "@mui/icons-material";
import Box from "@mui/material/Box";
import notifier from "../app/Notifier";
import config from "../app/config";
@@ -118,13 +118,9 @@ const NavList = (props) => {
<ListItemIcon><ArticleIcon/></ListItemIcon>
<ListItemText primary="Documentation"/>
</ListItemButton>
<ListItemButton onClick={() => props.onPublishMessageClick()}>
<ListItemIcon><Send/></ListItemIcon>
<ListItemText primary="Publish message"/>
</ListItemButton>
<ListItemButton onClick={() => setSubscribeDialogOpen(true)}>
<ListItemIcon><AddIcon/></ListItemIcon>
<ListItemText primary="Subscribe to topic"/>
<ListItemText primary="Add subscription"/>
</ListItemButton>
</List>
<SubscribeDialog

View File

@@ -20,8 +20,7 @@ import {
formatMessage,
formatShortDateTime,
formatTitle,
openUrl,
shortUrl,
openUrl, shortUrl,
topicShortUrl,
unmatchedTags
} from "../app/utils";
@@ -33,12 +32,16 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import subscriptionManager from "../app/SubscriptionManager";
import InfiniteScroll from "react-infinite-scroll-component";
import fileApp from "../img/file-app.svg";
import fileAudio from "../img/file-audio.svg";
import fileDocument from "../img/file-document.svg";
import fileImage from "../img/file-image.svg";
import fileVideo from "../img/file-video.svg";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import logoOutline from "../img/ntfy-outline.svg";
import AttachmentIcon from "./AttachmentIcon";
const Notifications = (props) => {
if (props.mode === "all") {
@@ -57,7 +60,7 @@ const AllSubscriptions = (props) => {
} else if (notifications.length === 0) {
return <NoNotificationsWithoutSubscription subscriptions={subscriptions}/>;
}
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
return <NotificationList key="all" notifications={notifications}/>;
}
const SingleSubscription = (props) => {
@@ -68,7 +71,7 @@ const SingleSubscription = (props) => {
} else if (notifications.length === 0) {
return <NoNotifications subscription={subscription}/>;
}
return <NotificationList id={subscription.id} notifications={notifications} messageBar={true}/>;
return <NotificationList id={subscription.id} notifications={notifications}/>;
}
const NotificationList = (props) => {
@@ -94,13 +97,7 @@ const NotificationList = (props) => {
scrollThreshold={0.7}
scrollableTarget="main"
>
<Container
maxWidth="md"
sx={{
marginTop: 3,
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
}}
>
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
<Stack spacing={3}>
{notifications.slice(0, count).map(notification =>
<NotificationItem
@@ -122,12 +119,13 @@ const NotificationList = (props) => {
const NotificationItem = (props) => {
const notification = props.notification;
const subscriptionId = notification.subscriptionId;
const attachment = notification.attachment;
const date = formatShortDateTime(notification.time);
const otherTags = unmatchedTags(notification.tags);
const tags = (otherTags.length > 0) ? otherTags.join(', ') : null;
const handleDelete = async () => {
console.log(`[Notifications] Deleting notification ${notification.id}`);
console.log(`[Notifications] Deleting notification ${notification.id} from ${subscriptionId}`);
await subscriptionManager.deleteNotification(notification.id)
}
const handleCopy = (s) => {
@@ -241,7 +239,7 @@ const Attachment = (props) => {
padding: 1,
borderRadius: '4px',
}}>
<AttachmentIcon type={attachment.type}/>
<Icon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -270,7 +268,7 @@ const Attachment = (props) => {
}
}}
>
<AttachmentIcon type={attachment.type}/>
<Icon type={attachment.type}/>
<Typography variant="body2" sx={{ marginLeft: 1, textAlign: 'left', color: 'text.primary' }}>
<b>{attachment.name}</b>
{maybeInfoText}
@@ -325,6 +323,35 @@ const Image = (props) => {
);
}
const Icon = (props) => {
const type = props.type;
let imageFile;
if (!type) {
imageFile = fileDocument;
} else if (type.startsWith('image/')) {
imageFile = fileImage;
} else if (type.startsWith('video/')) {
imageFile = fileVideo;
} else if (type.startsWith('audio/')) {
imageFile = fileAudio;
} else if (type === "application/vnd.android.package-archive") {
imageFile = fileApp;
} else {
imageFile = fileDocument;
}
return (
<Box
component="img"
src={imageFile}
loading="lazy"
sx={{
width: '28px',
height: '28px'
}}
/>
);
}
const NoNotifications = (props) => {
const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic);
return (

View File

@@ -1,655 +0,0 @@
import * as React from 'react';
import {useEffect, useRef, useState} from 'react';
import {NotificationItem} from "./Notifications";
import theme from "./theme";
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
import TextField from "@mui/material/TextField";
import priority1 from "../img/priority-1.svg";
import priority2 from "../img/priority-2.svg";
import priority3 from "../img/priority-3.svg";
import priority4 from "../img/priority-4.svg";
import priority5 from "../img/priority-5.svg";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
import {Close} from "@mui/icons-material";
import MenuItem from "@mui/material/MenuItem";
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter";
import api from "../app/Api";
import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker";
const SendDialog = (props) => {
const [baseUrl, setBaseUrl] = useState("");
const [topic, setTopic] = useState("");
const [message, setMessage] = useState("");
const [messageFocused, setMessageFocused] = useState(true);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [priority, setPriority] = useState(3);
const [clickUrl, setClickUrl] = useState("");
const [attachUrl, setAttachUrl] = useState("");
const [attachFile, setAttachFile] = useState(null);
const [filename, setFilename] = useState("");
const [filenameEdited, setFilenameEdited] = useState(false);
const [email, setEmail] = useState("");
const [delay, setDelay] = useState("");
const [publishAnother, setPublishAnother] = useState(false);
const [showTopicUrl, setShowTopicUrl] = useState("");
const [showClickUrl, setShowClickUrl] = useState(false);
const [showAttachUrl, setShowAttachUrl] = useState(false);
const [showEmail, setShowEmail] = useState(false);
const [showDelay, setShowDelay] = useState(false);
const showAttachFile = !!attachFile && !showAttachUrl;
const attachFileInput = useRef();
const [attachFileError, setAttachFileError] = useState("");
const [activeRequest, setActiveRequest] = useState(null);
const [status, setStatus] = useState("");
const disabled = !!activeRequest;
const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null);
const [dropZone, setDropZone] = useState(false);
const [sendButtonEnabled, setSendButtonEnabled] = useState(true);
const open = !!props.openMode;
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
useEffect(() => {
window.addEventListener('dragenter', () => {
props.onDragEnter();
setDropZone(true);
});
}, []);
useEffect(() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(!props.baseUrl || !props.topic);
setMessageFocused(!!props.topic); // Focus message only if topic is set
}, [props.baseUrl, props.topic]);
useEffect(() => {
const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError;
setSendButtonEnabled(valid);
}, [baseUrl, topic, attachFileError]);
useEffect(() => {
setMessage(props.message);
}, [props.message]);
const handleSubmit = async () => {
const url = new URL(topicUrl(baseUrl, topic));
if (title.trim()) {
url.searchParams.append("title", title.trim());
}
if (tags.trim()) {
url.searchParams.append("tags", tags.trim());
}
if (priority && priority !== 3) {
url.searchParams.append("priority", priority.toString());
}
if (clickUrl.trim()) {
url.searchParams.append("click", clickUrl.trim());
}
if (attachUrl.trim()) {
url.searchParams.append("attach", attachUrl.trim());
}
if (filename.trim()) {
url.searchParams.append("filename", filename.trim());
}
if (email.trim()) {
url.searchParams.append("email", email.trim());
}
if (delay.trim()) {
url.searchParams.append("delay", delay.trim());
}
if (attachFile && message.trim()) {
url.searchParams.append("message", message.replaceAll("\n", "\\n").trim());
}
const body = (attachFile) ? attachFile : message;
try {
const user = await userManager.get(baseUrl);
const headers = maybeWithBasicAuth({}, user);
const progressFn = (ev) => {
if (ev.loaded > 0 && ev.total > 0) {
const percent = Math.round(ev.loaded * 100.0 / ev.total);
setStatus(`Uploading ${formatBytes(ev.loaded)}/${formatBytes(ev.total)} (${percent}%) ...`);
} else {
setStatus(`Uploading ...`);
}
};
const request = api.publishXHR(url, body, headers, progressFn);
setActiveRequest(request);
await request;
if (!publishAnother) {
props.onClose();
} else {
setStatus("Message published");
setActiveRequest(null);
}
} catch (e) {
setStatus(<Typography sx={{color: 'error.main', maxWidth: "400px"}}>{e}</Typography>);
setActiveRequest(null);
}
};
const checkAttachmentLimits = async (file) => {
try {
const stats = await api.userStats(baseUrl);
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
if (fileSizeLimitReached && quotaReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit and quota, ${formatBytes(remainingBytes)} remaining`);
} else if (fileSizeLimitReached) {
return setAttachFileError(`exceeds ${formatBytes(fileSizeLimit)} file limit`);
} else if (quotaReached) {
return setAttachFileError(`exceeds quota, ${formatBytes(remainingBytes)} remaining`);
}
setAttachFileError("");
} catch (e) {
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
setAttachFileError(""); // Reset error (rely on server-side checking)
}
};
const handleAttachFileClick = () => {
attachFileInput.current.click();
};
const handleAttachFileChanged = async (ev) => {
await updateAttachFile(ev.target.files[0]);
};
const handleAttachFileDrop = async (ev) => {
ev.preventDefault();
setDropZone(false);
await updateAttachFile(ev.dataTransfer.files[0]);
};
const updateAttachFile = async (file) => {
setAttachFile(file);
setFilename(file.name);
props.onResetOpenMode();
await checkAttachmentLimits(file);
};
const handleAttachFileDragLeave = () => {
setDropZone(false);
if (props.openMode === SendDialog.OPEN_MODE_DRAG) {
props.onClose(); // Only close dialog if it was not open before dragging file in
}
};
const handleEmojiClick = (ev) => {
setEmojiPickerAnchorEl(ev.currentTarget);
};
const handleEmojiPick = (emoji) => {
setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji);
};
const handleEmojiClose = () => {
setEmojiPickerAnchorEl(null);
};
return (
<>
{dropZone && <DropArea
onDrop={handleAttachFileDrop}
onDragLeave={handleAttachFileDragLeave}/>
}
<Dialog maxWidth="md" open={open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{(baseUrl && topic) ? `Publish to ${topicShortUrl(baseUrl, topic)}` : "Publish message"}</DialogTitle>
<DialogContent>
{dropZone && <DropBox/>}
{showTopicUrl &&
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} onClose={() => {
setBaseUrl(props.baseUrl);
setTopic(props.topic);
setShowTopicUrl(false);
}}>
<TextField
margin="dense"
label="Server URL"
placeholder="Server URL, e.g. https://example.com"
value={baseUrl}
onChange={ev => setBaseUrl(ev.target.value)}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
/>
<TextField
margin="dense"
label="Topic"
placeholder="Topic name, e.g. phil_alerts"
value={topic}
onChange={ev => setTopic(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
autoFocus={!messageFocused}
sx={{flexGrow: 1}}
/>
</ClosableRow>
}
<TextField
margin="dense"
label="Title"
value={title}
onChange={ev => setTitle(ev.target.value)}
disabled={disabled}
type="text"
fullWidth
variant="standard"
placeholder="Notification title, e.g. Disk space alert"
/>
<TextField
margin="dense"
label="Message"
placeholder="Type a message here"
value={message}
onChange={ev => setMessage(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
rows={5}
autoFocus={messageFocused}
fullWidth
multiline
/>
<div style={{display: 'flex'}}>
<EmojiPicker
anchorEl={emojiPickerAnchorEl}
onEmojiPick={handleEmojiPick}
onClose={handleEmojiClose}
/>
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}>
<InsertEmoticonIcon/>
</DialogIconButton>
<TextField
margin="dense"
label="Tags"
placeholder="Comma-separated list of tags, e.g. warning, srv1-backup"
value={tags}
onChange={ev => setTags(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1, marginRight: 1}}
/>
<FormControl
variant="standard"
margin="dense"
sx={{minWidth: 120, maxWidth: 200, flexGrow: 1}}
>
<InputLabel/>
<Select
label="Priority"
margin="dense"
value={priority}
onChange={(ev) => setPriority(ev.target.value)}
disabled={disabled}
>
{[5,4,3,2,1].map(priority =>
<MenuItem key={`priorityMenuItem${priority}`} value={priority}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<img src={priorities[priority].file} style={{marginRight: "8px"}}/>
<div>{priorities[priority].label}</div>
</div>
</MenuItem>
)}
</Select>
</FormControl>
</div>
{showClickUrl &&
<ClosableRow disabled={disabled} onClose={() => {
setClickUrl("");
setShowClickUrl(false);
}}>
<TextField
margin="dense"
label="Click URL"
placeholder="URL that is opened when notification is clicked"
value={clickUrl}
onChange={ev => setClickUrl(ev.target.value)}
disabled={disabled}
type="url"
fullWidth
variant="standard"
/>
</ClosableRow>
}
{showEmail &&
<ClosableRow disabled={disabled} onClose={() => {
setEmail("");
setShowEmail(false);
}}>
<TextField
margin="dense"
label="Email"
placeholder="Address to forward the message to, e.g. phil@example.com"
value={email}
onChange={ev => setEmail(ev.target.value)}
disabled={disabled}
type="email"
variant="standard"
fullWidth
/>
</ClosableRow>
}
{showAttachUrl &&
<ClosableRow disabled={disabled} onClose={() => {
setAttachUrl("");
setFilename("");
setFilenameEdited(false);
setShowAttachUrl(false);
}}>
<TextField
margin="dense"
label="Attachment URL"
placeholder="Attach file by URL, e.g. https://f-droid.org/F-Droid.apk"
value={attachUrl}
onChange={ev => {
const url = ev.target.value;
setAttachUrl(url);
if (!filenameEdited) {
try {
const u = new URL(url);
const parts = u.pathname.split("/");
if (parts.length > 0) {
setFilename(parts[parts.length-1]);
}
} catch (e) {
// Do nothing
}
}
}}
disabled={disabled}
type="url"
variant="standard"
sx={{flexGrow: 5, marginRight: 1}}
/>
<TextField
margin="dense"
label="Filename"
placeholder="Attachment filename"
value={filename}
onChange={ev => {
setFilename(ev.target.value);
setFilenameEdited(true);
}}
disabled={disabled}
type="text"
variant="standard"
sx={{flexGrow: 1}}
/>
</ClosableRow>
}
<input
type="file"
ref={attachFileInput}
onChange={handleAttachFileChanged}
style={{ display: 'none' }}
/>
{showAttachFile && <AttachmentBox
file={attachFile}
filename={filename}
disabled={disabled}
error={attachFileError}
onChangeFilename={(f) => setFilename(f)}
onClose={() => {
setAttachFile(null);
setAttachFileError("");
setFilename("");
}}
/>}
{showDelay &&
<ClosableRow disabled={disabled} onClose={() => {
setDelay("");
setShowDelay(false);
}}>
<TextField
margin="dense"
label="Delay"
placeholder="Delay delivery, e.g. 1649029748, 30m, or tomorrow, 9am"
value={delay}
onChange={ev => setDelay(ev.target.value)}
disabled={disabled}
type="text"
variant="standard"
fullWidth
/>
</ClosableRow>
}
<Typography variant="body1" sx={{marginTop: 2, marginBottom: 1}}>
Other features:
</Typography>
<div>
{!showClickUrl && <Chip clickable disabled={disabled} label="Click URL" onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showEmail && <Chip clickable disabled={disabled} label="Forward to email" onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label="Attach file by URL" onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label="Attach local file" onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showDelay && <Chip clickable disabled={disabled} label="Delay delivery" onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
{!showTopicUrl && <Chip clickable disabled={disabled} label="Change topic" onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
</div>
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
For examples and a detailed description of all send features, please
refer to the <Link href="/docs" target="_blank">documentation</Link>.
</Typography>
</DialogContent>
<DialogFooter status={status}>
{activeRequest && <Button onClick={() => activeRequest.abort()}>Cancel sending</Button>}
{!activeRequest &&
<>
<FormControlLabel
label="Publish another"
sx={{marginRight: 2}}
control={
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
} />
<Button onClick={props.onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>Send</Button>
</>
}
</DialogFooter>
</Dialog>
</>
);
};
const Row = (props) => {
return (
<div style={{display: 'flex'}}>
{props.children}
</div>
);
};
const ClosableRow = (props) => {
const closable = (props.hasOwnProperty("closable")) ? props.closable : true;
return (
<Row>
{props.children}
{closable && <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>}
</Row>
);
};
const DialogIconButton = (props) => {
const sx = props.sx || {};
return (
<IconButton
color="inherit"
size="large"
edge="start"
sx={{height: "45px", marginTop: "17px", ...sx}}
onClick={props.onClick}
disabled={props.disabled}
>
{props.children}
</IconButton>
);
};
const AttachmentBox = (props) => {
const file = props.file;
return (
<>
<Typography variant="body1" sx={{marginTop: 2}}>
Attached file:
</Typography>
<Box sx={{
display: 'flex',
alignItems: 'center',
padding: 0.5,
borderRadius: '4px',
}}>
<AttachmentIcon type={file.type}/>
<Box sx={{ marginLeft: 1, textAlign: 'left' }}>
<ExpandingTextField
minWidth={140}
variant="body2"
value={props.filename}
onChange={(ev) => props.onChangeFilename(ev.target.value)}
disabled={props.disabled}
/>
<br/>
<Typography variant="body2" sx={{ color: 'text.primary' }}>
{formatBytes(file.size)}
{props.error &&
<Typography component="span" sx={{ color: 'error.main' }}>
{" "}({props.error})
</Typography>
}
</Typography>
</Box>
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
</Box>
</>
);
};
const ExpandingTextField = (props) => {
const invisibleFieldRef = useRef();
const [textWidth, setTextWidth] = useState(props.minWidth);
const determineTextWidth = () => {
const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect();
if (!boundingRect) {
return props.minWidth;
}
return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth;
};
useEffect(() => {
setTextWidth(determineTextWidth() + 5);
}, [props.value]);
return (
<>
<Typography
ref={invisibleFieldRef}
component="span"
variant={props.variant}
sx={{position: "absolute", left: "-200%"}}
>
{props.value}
</Typography>
<TextField
margin="dense"
placeholder="Attachment filename"
value={props.value}
onChange={props.onChange}
type="text"
variant="standard"
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
disabled={props.disabled}
/>
</>
)
};
const DropArea = (props) => {
const allowDrag = (ev) => {
// This is where we could disallow certain files to be dragged in.
// For now we allow all files.
ev.dataTransfer.dropEffect = 'copy';
ev.preventDefault();
};
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10002,
}}
onDrop={props.onDrop}
onDragEnter={allowDrag}
onDragOver={allowDrag}
onDragLeave={props.onDragLeave}
/>
);
};
const DropBox = () => {
return (
<Box sx={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
zIndex: 10000,
backgroundColor: "#ffffffbb"
}}>
<Box
sx={{
position: 'absolute',
border: '3px dashed #ccc',
borderRadius: '5px',
left: "40px",
top: "40px",
right: "40px",
bottom: "40px",
zIndex: 10001,
display: 'flex',
justifyContent: "center",
alignItems: "center",
}}
>
<Typography variant="h5">Drop file here</Typography>
</Box>
</Box>
);
}
const priorities = {
1: { label: "Min. priority", file: priority1 },
2: { label: "Low priority", file: priority2 },
3: { label: "Default priority", file: priority3 },
4: { label: "High priority", file: priority4 },
5: { label: "Max. priority", file: priority5 }
};
SendDialog.OPEN_MODE_DEFAULT = "default";
SendDialog.OPEN_MODE_DRAG = "drag";
export default SendDialog;

View File

@@ -3,6 +3,7 @@ import {useState} from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
@@ -10,10 +11,10 @@ import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/mate
import theme from "./theme";
import api from "../app/Api";
import {topicUrl, validTopic, validUrl} from "../app/utils";
import Box from "@mui/material/Box";
import userManager from "../app/UserManager";
import subscriptionManager from "../app/SubscriptionManager";
import poller from "../app/Poller";
import DialogFooter from "./DialogFooter";
const publicBaseUrl = "https://ntfy.sh";
@@ -187,4 +188,27 @@ const LoginPage = (props) => {
);
};
const DialogFooter = (props) => {
return (
<Box sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
paddingLeft: '24px',
paddingTop: '8px 24px',
paddingBottom: '8px 24px',
}}>
<DialogContentText sx={{
margin: '0px',
paddingTop: '8px',
}}>
{props.status}
</DialogContentText>
<DialogActions>
{props.children}
</DialogActions>
</Box>
);
};
export default SubscribeDialog;

View File

@@ -6,7 +6,6 @@ import notifier from "../app/Notifier";
import routes from "./routes";
import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller";
import pruner from "../app/Pruner";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -68,13 +67,29 @@ export const useAutoSubscribe = (subscriptions, selected) => {
};
/**
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
* up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186.
* Migrate the 'topics' item in localStorage to the subscriptionManager. This is only done once to migrate away
* from the old web UI.
*/
export const useBackgroundProcesses = () => {
export const useLocalStorageMigration = () => {
const [hasRun, setHasRun] = useState(false);
useEffect(() => {
poller.startWorker();
pruner.startWorker();
if (hasRun) {
return;
}
const topicsStr = localStorage.getItem("topics");
if (topicsStr) {
const topics = JSON.parse(topicsStr).filter(topic => topic !== "");
if (topics.length > 0) {
(async () => {
for (const topic of topics) {
const baseUrl = window.location.origin;
const subscription = await subscriptionManager.add(baseUrl, topic);
poller.pollInBackground(subscription); // Dangle!
}
localStorage.removeItem("topics");
})();
}
}
setHasRun(true);
}, []);
}

View File

@@ -1 +0,0 @@
<svg height="24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M4.882 16.057c-.33-.114-.636-.42-.795-.797-.084-.202-.1-.292-.1-.578-.001-.302.011-.366.116-.6a1.44 1.44 0 01.652-.704l.205-.106h14.077l.205.106c.756.39 1.01 1.376.548 2.128a1.217 1.217 0 01-.588.515l-.201.091-6.985-.001c-5.641-.002-7.013-.012-7.134-.054zM4.858 10.595c-.33-.114-.635-.42-.794-.797-.085-.201-.1-.292-.101-.578 0-.302.012-.366.116-.6a1.44 1.44 0 01.653-.704l.205-.106h14.076l.205.106c.757.39 1.01 1.377.548 2.128a1.217 1.217 0 01-.587.515l-.202.092-6.984-.002c-5.642-.002-7.013-.012-7.135-.054z" fill="#0091ff"/></svg>

Before

Width:  |  Height:  |  Size: 605 B