mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf6b8bb28 | ||
|
|
cb82e2690c | ||
|
|
ab1f9220a3 | ||
|
|
4c5d40e4c9 | ||
|
|
c33065151e | ||
|
|
ab01d0f04e | ||
|
|
42c3c6eb29 | ||
|
|
da7a1f656f | ||
|
|
63719ca0a0 | ||
|
|
cd27d47f4b | ||
|
|
60c5ccf34b | ||
|
|
d819de2626 | ||
|
|
79cb082879 | ||
|
|
632bf8d0b6 | ||
|
|
5e1d1698ff | ||
|
|
396497fff7 | ||
|
|
94bfe029d5 | ||
|
|
7f736eb93e | ||
|
|
414e283b46 | ||
|
|
a52e628f7b | ||
|
|
3eea97109e | ||
|
|
1950fc518f | ||
|
|
b93d654aca | ||
|
|
91594faf28 | ||
|
|
6c2aa0c3c2 | ||
|
|
db613f81cc | ||
|
|
51769c4094 | ||
|
|
cb768ca3f8 | ||
|
|
433e8e5b99 | ||
|
|
6cb42fbca1 | ||
|
|
406c172230 | ||
|
|
b4fbe81bb4 | ||
|
|
28f211bfef | ||
|
|
891257cce8 | ||
|
|
4cae237b36 | ||
|
|
c684a39191 | ||
|
|
797e4640df | ||
|
|
9684629549 | ||
|
|
a2825880bc | ||
|
|
577cd0fcea | ||
|
|
4b86085a8c | ||
|
|
0ee99e10c8 | ||
|
|
fe96110e6b | ||
|
|
35f173e17c | ||
|
|
87f8af9b97 | ||
|
|
4dd215d3d8 | ||
|
|
5a8818ac92 | ||
|
|
3baa93a0d4 | ||
|
|
72ec2f9988 | ||
|
|
ae3d063c2d | ||
|
|
d0bb27cf0c | ||
|
|
67be8e3ff8 | ||
|
|
4571ba1c24 | ||
|
|
6d601ad141 | ||
|
|
f63b15ba5a | ||
|
|
5c01d13fe3 | ||
|
|
19d2a46457 | ||
|
|
613348d37e | ||
|
|
7d473488de | ||
|
|
6e4b31b4e9 | ||
|
|
88474957a2 | ||
|
|
9dc532de30 | ||
|
|
fe37258bc2 | ||
|
|
5291e9be7f | ||
|
|
6ab02a31a2 | ||
|
|
14d9d120e6 | ||
|
|
f5981b851d | ||
|
|
c357979f11 | ||
|
|
6ee3349cca | ||
|
|
91e6eaab19 | ||
|
|
3973f1e5ed | ||
|
|
15ac5ed23b | ||
|
|
344da326cd | ||
|
|
cacfb704a4 | ||
|
|
040bb53383 | ||
|
|
5cac63bfbe | ||
|
|
8d908fe438 | ||
|
|
7db99d18c7 | ||
|
|
2bb5d6f934 | ||
|
|
bb13011046 | ||
|
|
8cc12e12da | ||
|
|
6e2b300d9e | ||
|
|
1197d72523 | ||
|
|
c1517e259d | ||
|
|
66d30fb42a | ||
|
|
ea30132763 | ||
|
|
1c237435ec | ||
|
|
c37793d06f | ||
|
|
57c7a353b5 | ||
|
|
8154705f0b | ||
|
|
fd2c6ef590 | ||
|
|
6ece25e7f3 | ||
|
|
a2ef6180bb | ||
|
|
c3c4c9e9aa | ||
|
|
031c848984 | ||
|
|
1f70ff1b06 | ||
|
|
1af9a85847 | ||
|
|
e474d1e8b0 | ||
|
|
4b117a790a | ||
|
|
72a2a8c82e | ||
|
|
a7af16beb1 | ||
|
|
7d38bc7654 | ||
|
|
bcbcbf12ac | ||
|
|
8b5d2a8ca0 | ||
|
|
9b8e637618 | ||
|
|
15a45d9eb7 | ||
|
|
8a7bc38861 | ||
|
|
2d96560375 | ||
|
|
bb5e0e3fed | ||
|
|
4a8678bf39 | ||
|
|
ed28082c01 | ||
|
|
0d3dcfdc7a | ||
|
|
672203467d | ||
|
|
4ce619f9cb | ||
|
|
5344337b43 | ||
|
|
cf3238859c | ||
|
|
9a03a9e81b | ||
|
|
edfed24c27 | ||
|
|
7118dcc124 | ||
|
|
5bcb35f756 | ||
|
|
eaf3c42227 | ||
|
|
16a4feaeb6 | ||
|
|
b60458318c | ||
|
|
b10c88afd7 | ||
|
|
f0cae0fbac | ||
|
|
28bb8d4446 | ||
|
|
adea3c38be | ||
|
|
fb56ab9a06 | ||
|
|
72aea2613a | ||
|
|
6bd4e4bd7c | ||
|
|
1f6118f068 | ||
|
|
574e72a974 | ||
|
|
53646737e8 | ||
|
|
26b9cc75ca |
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
72
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '21 10 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'go', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,9 +1,12 @@
|
||||
dist/
|
||||
build/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
secrets/
|
||||
*.iml
|
||||
node_modules/
|
||||
|
||||
@@ -4,7 +4,7 @@ before:
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy_amd64
|
||||
id: ntfy_linux_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -17,7 +17,7 @@ builds:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_armv6
|
||||
id: ntfy_linux_armv6
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -28,10 +28,9 @@ builds:
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [6]
|
||||
# No "upx", since it causes random core dumps, see
|
||||
# https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_armv7
|
||||
id: ntfy_linux_armv7
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -42,10 +41,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
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_arm64
|
||||
id: ntfy_linux_arm64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
@@ -55,8 +53,30 @@ 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
|
||||
# No "upx" for ARM, see https://github.com/binwiederhier/ntfy/issues/191#issuecomment-1083406546
|
||||
-
|
||||
id: ntfy_windows_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [windows]
|
||||
goarch: [amd64]
|
||||
hooks:
|
||||
post:
|
||||
- upx "{{ .Path }}" # apt install upx
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
||||
nfpms:
|
||||
-
|
||||
package_name: ntfy
|
||||
@@ -94,6 +114,12 @@ nfpms:
|
||||
postremove: "scripts/postrm.sh"
|
||||
archives:
|
||||
-
|
||||
id: ntfy_linux
|
||||
builds:
|
||||
- ntfy_linux_amd64
|
||||
- ntfy_linux_armv6
|
||||
- ntfy_linux_armv7
|
||||
- ntfy_linux_arm64
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
@@ -103,8 +129,34 @@ archives:
|
||||
- client/client.yml
|
||||
- client/ntfy-client.service
|
||||
replacements:
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
-
|
||||
id: ntfy_windows
|
||||
builds:
|
||||
- ntfy_windows_amd64
|
||||
format: zip
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- client/client.yml
|
||||
replacements:
|
||||
amd64: x86_64
|
||||
-
|
||||
id: ntfy_darwin
|
||||
builds:
|
||||
- ntfy_darwin_all
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- client/client.yml
|
||||
replacements:
|
||||
darwin: macOS
|
||||
universal_binaries:
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
replace: true
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
|
||||
198
Makefile
198
Makefile
@@ -1,64 +1,72 @@
|
||||
MAKEFLAGS := --jobs=1
|
||||
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 " make build - Build web app, documentation and server/client (sloowwww)"
|
||||
@echo " make cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)"
|
||||
@echo " make install-linux-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 " 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-armv6 - Build server & client (armv6 only)"
|
||||
@echo " make server-armv7 - Build server & client (armv7 only)"
|
||||
@echo " make server-arm64 - Build server & client (arm64 only)"
|
||||
@echo "Build server & client (using GoReleaser, not release version):"
|
||||
@echo " make cli - Build server & client (all architectures)"
|
||||
@echo " make cli-linux-amd64 - Build server & client (Linux, amd64 only)"
|
||||
@echo " make cli-linux-armv6 - Build server & client (Linux, armv6 only)"
|
||||
@echo " make cli-linux-armv7 - Build server & client (Linux, armv7 only)"
|
||||
@echo " make cli-linux-arm64 - Build server & client (Linux, arm64 only)"
|
||||
@echo " make cli-windows-amd64 - Build client (Windows, amd64 only)"
|
||||
@echo " make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)"
|
||||
@echo
|
||||
@echo "Build server & client (without GoReleaser):"
|
||||
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
|
||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||
@echo
|
||||
@echo "Build web app:"
|
||||
@echo " make web - Build the web app"
|
||||
@echo " make web-deps - Install web app dependencies (npm install the universe)"
|
||||
@echo " make web-build - Actually build the web app"
|
||||
@echo " make web - 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 " make docs - Build the documentation"
|
||||
@echo " make docs-deps - Install Python dependencies (pip3 install)"
|
||||
@echo " make docs-build - Actually build the documentation"
|
||||
@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 " 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-armv6 - Copy armv6 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-armv6 - Install .deb from dist/ (armv6 only)"
|
||||
@echo " make install-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||
@echo " make install-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||
@echo " make install-linux-amd64 - Copy amd64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv6 - Copy armv6 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-armv7 - Copy armv7 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-arm64 - Copy arm64 binary from dist/ to /usr/bin/ntfy"
|
||||
@echo " make install-linux-deb-amd64 - Install .deb from dist/ (amd64 only)"
|
||||
@echo " make install-linux-deb-armv6 - Install .deb from dist/ (armv6 only)"
|
||||
@echo " make install-linux-deb-armv7 - Install .deb from dist/ (armv7 only)"
|
||||
@echo " make install-linux-deb-arm64 - Install .deb from dist/ (arm64 only)"
|
||||
|
||||
|
||||
# Building everything
|
||||
@@ -66,28 +74,29 @@ help:
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
build: web docs server
|
||||
build: web docs cli
|
||||
|
||||
update: web-deps-update cli-deps-update docs-deps-update
|
||||
|
||||
|
||||
# Documentation
|
||||
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-build: .PHONY
|
||||
mkdocs build
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs-build: .PHONY
|
||||
mkdocs build
|
||||
docs-deps-update: .PHONY
|
||||
pip3 install -r requirements.txt --upgrade
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
web-deps:
|
||||
cd web && npm install
|
||||
# If this fails for .svg files, optimizes them with svgo
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
@@ -98,41 +107,86 @@ web-build:
|
||||
../server/site/config.js \
|
||||
../server/site/asset-manifest.json
|
||||
|
||||
web-deps:
|
||||
cd web && npm install
|
||||
# If this fails for .svg files, optimize them with svgo
|
||||
|
||||
web-deps-update:
|
||||
cd web && npm update
|
||||
|
||||
# Main server/client build
|
||||
|
||||
server: server-deps
|
||||
cli: cli-deps
|
||||
goreleaser build --snapshot --rm-dist --debug
|
||||
|
||||
server-amd64: server-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_amd64
|
||||
cli-linux-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_amd64
|
||||
|
||||
server-armv6: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv6
|
||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv6
|
||||
|
||||
server-armv7: server-deps-static-sites server-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_armv7
|
||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_armv7
|
||||
|
||||
server-arm64: server-deps-static-sites server-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_arm64
|
||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_linux_arm64
|
||||
|
||||
server-deps: server-deps-static-sites server-deps-all server-deps-gcc
|
||||
cli-windows-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_windows_amd64
|
||||
|
||||
server-deps-gcc: server-deps-gcc-armv6-armv7 server-deps-gcc-arm64
|
||||
cli-darwin-all: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --debug --id ntfy_darwin_all
|
||||
|
||||
server-deps-static-sites:
|
||||
cli-linux-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_linux_server server/docs
|
||||
CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_linux_server/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)"
|
||||
|
||||
cli-darwin-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
# Use this for macOS/iOS development, so you have a local server to test with.
|
||||
mkdir -p dist/ntfy_darwin_server server/docs
|
||||
CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_darwin_server/ntfy \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-client: cli-deps-static-sites
|
||||
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_client server/docs
|
||||
CGO_ENABLED=0 go build \
|
||||
-o dist/ntfy_client/ntfy \
|
||||
-tags noserver \
|
||||
-ldflags \
|
||||
"-X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
|
||||
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
|
||||
|
||||
cli-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
server-deps-all:
|
||||
cli-deps-all:
|
||||
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||
|
||||
server-deps-gcc-armv6-armv7:
|
||||
cli-deps-gcc-armv6-armv7:
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
|
||||
server-deps-gcc-arm64:
|
||||
cli-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; }
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
# Test/check targets
|
||||
|
||||
@@ -184,10 +238,10 @@ staticcheck: .PHONY
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean server-deps release-check-tags docs web check
|
||||
release: clean update cli-deps release-check-tags docs web check
|
||||
goreleaser release --rm-dist --debug
|
||||
|
||||
release-snapshot: clean server-deps docs web check
|
||||
release-snapshot: clean update cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --rm-dist --debug
|
||||
|
||||
release-check-tags:
|
||||
@@ -204,31 +258,31 @@ release-check-tags:
|
||||
|
||||
# Installing targets
|
||||
|
||||
install-amd64: remove-binary
|
||||
install-linux-amd64: remove-binary
|
||||
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
|
||||
install-armv6: remove-binary
|
||||
install-linux-armv6: remove-binary
|
||||
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
|
||||
install-armv7: remove-binary
|
||||
install-linux-armv7: remove-binary
|
||||
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
|
||||
install-arm64: remove-binary
|
||||
install-linux-arm64: remove-binary
|
||||
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
|
||||
remove-binary:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
|
||||
install-amd64-deb: purge-package
|
||||
install-linux-amd64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_amd64.deb
|
||||
|
||||
install-armv6-deb: purge-package
|
||||
install-linux-armv6-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv6.deb
|
||||
|
||||
install-armv7-deb: purge-package
|
||||
install-linux-armv7-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_armv7.deb
|
||||
|
||||
install-arm64-deb: purge-package
|
||||
install-linux-arm64-deb: purge-package
|
||||
sudo dpkg -i dist/ntfy_*_linux_arm64.deb
|
||||
|
||||
purge-package:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -8,6 +10,10 @@ import (
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdAccess)
|
||||
}
|
||||
|
||||
const (
|
||||
userEveryone = "everyone"
|
||||
)
|
||||
@@ -22,7 +28,7 @@ var cmdAccess = &cli.Command{
|
||||
Usage: "Grant/revoke access to a topic, or show access",
|
||||
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
|
||||
Flags: flagsAccess,
|
||||
Before: initConfigFileInputSource("config", flagsAccess),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsAccess),
|
||||
Action: execUserAccess,
|
||||
Category: categoryServer,
|
||||
Description: `Manage the access control list for the ntfy server.
|
||||
|
||||
39
cmd/app.go
39
cmd/app.go
@@ -2,23 +2,17 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/util"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultClientRootConfigFile = "/etc/ntfy/client.yml"
|
||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
||||
)
|
||||
|
||||
const (
|
||||
categoryClient = "Client commands"
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
var commands = make([]*cli.Command, 0)
|
||||
|
||||
// New creates a new CLI application
|
||||
func New() *cli.App {
|
||||
return &cli.App{
|
||||
@@ -30,33 +24,6 @@ func New() *cli.App {
|
||||
Reader: os.Stdin,
|
||||
Writer: os.Stdout,
|
||||
ErrWriter: os.Stderr,
|
||||
Commands: []*cli.Command{
|
||||
// Server commands
|
||||
cmdServe,
|
||||
cmdUser,
|
||||
cmdAccess,
|
||||
|
||||
// Client commands
|
||||
cmdPublish,
|
||||
cmdSubscribe,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||
func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
||||
return func(context *cli.Context) error {
|
||||
configFile := context.String(configFlag)
|
||||
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return fmt.Errorf("config file %s does not exist", configFile)
|
||||
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return nil
|
||||
}
|
||||
inputSource, err := altsrc.NewYamlSourceFromFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
52
cmd/config_loader.go
Normal file
52
cmd/config_loader.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/util"
|
||||
"os"
|
||||
)
|
||||
|
||||
// initConfigFileInputSourceFunc is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks
|
||||
// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails.
|
||||
func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag) cli.BeforeFunc {
|
||||
return func(context *cli.Context) error {
|
||||
configFile := context.String(configFlag)
|
||||
if context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return fmt.Errorf("config file %s does not exist", configFile)
|
||||
} else if !context.IsSet(configFlag) && !util.FileExists(configFile) {
|
||||
return nil
|
||||
}
|
||||
inputSource, err := newYamlSourceFromFile(configFile, flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||
}
|
||||
}
|
||||
|
||||
// newYamlSourceFromFile creates a new Yaml InputSourceContext from a filepath.
|
||||
//
|
||||
// This function also maps aliases, so a .yml file can contain short options, or options with underscores
|
||||
// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.
|
||||
func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {
|
||||
var rawConfig map[interface{}]interface{}
|
||||
b, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := yaml.Unmarshal(b, &rawConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range flags {
|
||||
flagName := f.Names()[0]
|
||||
for _, flagAlias := range f.Names()[1:] {
|
||||
if _, ok := rawConfig[flagAlias]; ok {
|
||||
rawConfig[flagName] = rawConfig[flagAlias]
|
||||
}
|
||||
}
|
||||
}
|
||||
return altsrc.NewMapInputSource(file, rawConfig), nil
|
||||
}
|
||||
38
cmd/config_loader_test.go
Normal file
38
cmd/config_loader_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewYamlSourceFromFile(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "server.yml")
|
||||
contents := `
|
||||
# Normal options
|
||||
listen-https: ":10443"
|
||||
|
||||
# Note the underscore!
|
||||
listen_http: ":1080"
|
||||
|
||||
# OMG this is allowed now ...
|
||||
K: /some/file.pem
|
||||
`
|
||||
require.Nil(t, os.WriteFile(filename, []byte(contents), 0600))
|
||||
|
||||
ctx, err := newYamlSourceFromFile(filename, flagsServe)
|
||||
require.Nil(t, err)
|
||||
|
||||
listenHTTPS, err := ctx.String("listen-https")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, ":10443", listenHTTPS)
|
||||
|
||||
listenHTTP, err := ctx.String("listen-http") // No underscore!
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, ":1080", listenHTTP)
|
||||
|
||||
keyFile, err := ctx.String("key-file") // Long option!
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/some/file.pem", keyFile)
|
||||
}
|
||||
@@ -12,6 +12,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdPublish)
|
||||
}
|
||||
|
||||
var cmdPublish = &cli.Command{
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
@@ -59,8 +63,7 @@ Examples:
|
||||
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
|
||||
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||
|
||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execPublish(c *cli.Context) error {
|
||||
|
||||
94
cmd/serve.go
94
cmd/serve.go
@@ -1,56 +1,63 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdServe)
|
||||
}
|
||||
|
||||
var flagsServe = []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
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"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
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: "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)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "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", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"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", Aliases: []string{"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", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
}
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
@@ -60,7 +67,7 @@ var cmdServe = &cli.Command{
|
||||
Action: execServe,
|
||||
Category: categoryServer,
|
||||
Flags: flagsServe,
|
||||
Before: initConfigFileInputSource("config", flagsServe),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsServe),
|
||||
Description: `Run the ntfy server and listen for incoming requests
|
||||
|
||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||
@@ -138,12 +145,14 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
} else if !util.InStringList([]string{"app", "home"}, webRoot) {
|
||||
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
|
||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
webRootIsApp := webRoot == "app"
|
||||
enableWeb := webRoot != "disable"
|
||||
|
||||
// Default auth permissions
|
||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||
|
||||
@@ -223,6 +232,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.EnableWeb = enableWeb
|
||||
s, err := server.New(conf)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
|
||||
@@ -10,9 +10,20 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdSubscribe)
|
||||
}
|
||||
|
||||
const (
|
||||
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||
)
|
||||
|
||||
var cmdSubscribe = &cli.Command{
|
||||
Name: "subscribe",
|
||||
Aliases: []string{"sub"},
|
||||
@@ -60,19 +71,17 @@ ntfy subscribe TOPIC COMMAND
|
||||
|
||||
Examples:
|
||||
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
|
||||
ntfy sub topic1 /my/script.sh # Execute script for incoming messages
|
||||
ntfy sub topic1 myscript.sh # Execute script for incoming messages
|
||||
|
||||
ntfy subscribe --from-config
|
||||
Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
|
||||
or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
|
||||
block (see config file).
|
||||
Service mode (used in ntfy-client.service). This reads the config file and sets up
|
||||
subscriptions for every topic in the "subscribe:" block (see config file).
|
||||
|
||||
Examples:
|
||||
ntfy sub --from-config # Read topics from config file
|
||||
ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
|
||||
ntfy sub --config=myclient.yml --from-config # Read topics from alternate config file
|
||||
|
||||
The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`,
|
||||
` + clientCommandDescriptionSuffix,
|
||||
}
|
||||
|
||||
func execSubscribe(c *cli.Context) error {
|
||||
@@ -156,8 +165,8 @@ func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, opti
|
||||
}
|
||||
|
||||
func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||
commands := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
cmds := make(map[string]string) // Subscription ID -> command
|
||||
for _, s := range conf.Subscribe { // May be nil
|
||||
topicOptions := append(make([]client.SubscribeOption, 0), options...)
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
@@ -166,18 +175,18 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
||||
}
|
||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||
commands[subscriptionID] = s.Command
|
||||
cmds[subscriptionID] = s.Command
|
||||
}
|
||||
if topic != "" {
|
||||
subscriptionID := cl.Subscribe(topic, options...)
|
||||
commands[subscriptionID] = command
|
||||
cmds[subscriptionID] = command
|
||||
}
|
||||
for m := range cl.Messages {
|
||||
command, ok := commands[m.SubscriptionID]
|
||||
cmd, ok := cmds[m.SubscriptionID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
printMessageOrRunCommand(c, m, command)
|
||||
printMessageOrRunCommand(c, m, cmd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -196,17 +205,17 @@ func runCommand(c *cli.Context, command string, m *client.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
|
||||
scriptFile, err := createTmpScript(command)
|
||||
if err != nil {
|
||||
func runCommandInternal(c *cli.Context, script string, m *client.Message) error {
|
||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.%s", os.TempDir(), util.RandomString(10), scriptExt)
|
||||
if err := os.WriteFile(scriptFile, []byte(scriptHeader+script), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(scriptFile)
|
||||
verbose := c.Bool("verbose")
|
||||
if verbose {
|
||||
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
||||
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), script, m.Raw)
|
||||
}
|
||||
cmd := exec.Command("sh", "-c", scriptFile)
|
||||
cmd := exec.Command(scriptLauncher[0], append(scriptLauncher[1:], scriptFile)...)
|
||||
cmd.Stdin = c.App.Reader
|
||||
cmd.Stdout = c.App.Writer
|
||||
cmd.Stderr = c.App.ErrWriter
|
||||
@@ -214,15 +223,6 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func createTmpScript(command string) (string, error) {
|
||||
scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
|
||||
script := fmt.Sprintf("#!/bin/sh\n%s", command)
|
||||
if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return scriptFile, nil
|
||||
}
|
||||
|
||||
func envVars(m *client.Message) []string {
|
||||
env := os.Environ()
|
||||
env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
|
||||
@@ -249,13 +249,26 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
u, _ := user.Current()
|
||||
configFile := defaultClientRootConfigFile
|
||||
if u.Uid != "0" {
|
||||
configFile = util.ExpandHome(defaultClientUserConfigFile)
|
||||
}
|
||||
configFile := defaultConfigFile()
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
}
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultConfigFileUnix() string {
|
||||
u, _ := user.Current()
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
|
||||
}
|
||||
return configFile
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultConfigFileWindows() string {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
|
||||
}
|
||||
|
||||
16
cmd/subscribe_darwin.go
Normal file
16
cmd/subscribe_darwin.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "sh"
|
||||
scriptHeader = "#!/bin/sh\n"
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultConfigFile() string {
|
||||
return defaultConfigFileUnix()
|
||||
}
|
||||
16
cmd/subscribe_linux.go
Normal file
16
cmd/subscribe_linux.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "sh"
|
||||
scriptHeader = "#!/bin/sh\n"
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is /etc/ntfy/client.yml (if root user),
|
||||
or ~/.config/ntfy/client.yml for all other users.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultConfigFile() string {
|
||||
return defaultConfigFileUnix()
|
||||
}
|
||||
15
cmd/subscribe_windows.go
Normal file
15
cmd/subscribe_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package cmd
|
||||
|
||||
const (
|
||||
scriptExt = "bat"
|
||||
scriptHeader = ""
|
||||
clientCommandDescriptionSuffix = `The default config file for all client commands is %AppData%\ntfy\client.yml.`
|
||||
)
|
||||
|
||||
var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultConfigFile() string {
|
||||
return defaultConfigFileWindows()
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -11,13 +13,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdUser)
|
||||
}
|
||||
|
||||
var flagsUser = userCommandFlags()
|
||||
|
||||
var cmdUser = &cli.Command{
|
||||
Name: "user",
|
||||
Usage: "Manage/show users",
|
||||
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
|
||||
Flags: flagsUser,
|
||||
Before: initConfigFileInputSource("config", flagsUser),
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
|
||||
133
docs/config.md
133
docs/config.md
@@ -227,7 +227,7 @@ The easiest way to configure a private instance is to set `auth-default-access`
|
||||
|
||||
=== "/etc/ntfy/server.yml"
|
||||
``` yaml
|
||||
auth-file "/var/lib/ntfy/user.db"
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
auth-default-access: "deny-all"
|
||||
```
|
||||
|
||||
@@ -519,24 +519,27 @@ or the root domain:
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
ServerName ntfy.sh
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
|
||||
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
@@ -548,26 +551,24 @@ or the root domain:
|
||||
SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
|
||||
# Proxy connections to ntfy (requires "a2enmod proxy")
|
||||
ProxyPass / http://127.0.0.1:2586/
|
||||
ProxyPassReverse / http://127.0.0.1:2586/
|
||||
|
||||
|
||||
SetEnv proxy-nokeepalive 1
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Higher than the max message size of 4096 bytes
|
||||
LimitRequestBody 102400
|
||||
|
||||
# WebSockets support
|
||||
# Enable mod_rewrite (requires "a2enmod rewrite")
|
||||
RewriteEngine on
|
||||
|
||||
# WebSockets support (requires "a2enmod rewrite proxy_wstunnel")
|
||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
RewriteEngine on
|
||||
RewriteCond %{REQUEST_METHOD} GET
|
||||
RewriteRule ^/([-_A-Za-z0-9]{0,64})$ https://%{SERVER_NAME}/$1 [R,L]
|
||||
RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L]
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
@@ -578,6 +579,15 @@ or the root domain:
|
||||
|
||||
ntfy.sh, http://nfty.sh {
|
||||
reverse_proxy 127.0.0.1:2586
|
||||
|
||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||
# it to work with curl without the annoying https:// prefix
|
||||
@httpget {
|
||||
protocol http
|
||||
method GET
|
||||
path_regexp ^/([-_a-z0-9]{0,64}$|docs/|static/)
|
||||
}
|
||||
redir @httpget https://{host}{uri}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -765,6 +775,11 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l
|
||||
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
||||
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
|
||||
!!! info
|
||||
All config options can also be defined in the `server.yml` file using underscores instead of dashes, e.g.
|
||||
`cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do
|
||||
not support dashes.
|
||||
|
||||
| Config option | Env variable | Format | Default | Description |
|
||||
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
||||
@@ -792,7 +807,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
|
||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
|
||||
@@ -829,42 +844,42 @@ DESCRIPTION:
|
||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||
|
||||
OPTIONS:
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
--listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||
--smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--help, -h show help (default: false)
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--help, -h show help (default: false)
|
||||
```
|
||||
|
||||
|
||||
@@ -112,15 +112,15 @@ by typing `make`:
|
||||
$ 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 cli-linux-amd64 - Build server/client binary (amd64, no web app or docs)
|
||||
make install-linux-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),
|
||||
If you want to build the **ntfy binary including web app and docs for all supported architectures** (amd64, armv7, and arm64),
|
||||
you can simply run `make build`:
|
||||
|
||||
``` shell
|
||||
@@ -158,47 +158,52 @@ $ make release-snapshot
|
||||
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:
|
||||
To build only the `ntfy` binary **without the web app or documentation**, use the `make cli-...` 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)
|
||||
Build server & client (using GoReleaser, not release version):
|
||||
make cli - Build server & client (all architectures)
|
||||
make cli-linux-amd64 - Build server & client (Linux, amd64 only)
|
||||
make cli-linux-armv6 - Build server & client (Linux, armv6 only)
|
||||
make cli-linux-armv7 - Build server & client (Linux, armv7 only)
|
||||
make cli-linux-arm64 - Build server & client (Linux, arm64 only)
|
||||
make cli-windows-amd64 - Build client (Windows, amd64 only)
|
||||
make cli-darwin-all - Build client (macOS, arm64+amd64 universal binary)
|
||||
```
|
||||
|
||||
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
|
||||
So if you're on an amd64/x86_64-based machine, you may just want to run `make cli-linux-amd64` during testing. On a modern
|
||||
system, this shouldn't take longer than 5-10 seconds. I often combine it with `install-linux-amd64` so I can run the binary
|
||||
right away:
|
||||
|
||||
``` shell
|
||||
$ make server-amd64 install-amd64
|
||||
$ make cli-linux-amd64 install-linux-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`:
|
||||
`make cli-deps-static-sites`at least once and `CGO_ENABLED=1`:
|
||||
|
||||
``` shell
|
||||
$ export CGO_ENABLED=1
|
||||
$ make server-deps-static-sites
|
||||
$ make cli-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`*:
|
||||
If you don't run `cli-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.
|
||||
present at `server/docs` and `server/site`. If they are not, you'll see the above error. The `cli-deps-static-sites`
|
||||
target creates dummy files that ensure that you'll be able to build.
|
||||
|
||||
While not officially supported (or released), you can build and run the server **on macOS** as well. Simply run
|
||||
`make cli-darwin-server` to build a binary, or `go run main.go serve` (see above) to run it.
|
||||
|
||||
### 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
|
||||
@@ -210,7 +215,7 @@ $ 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.
|
||||
that when you `make cli` (or `make cli-linux-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
|
||||
@@ -282,9 +287,13 @@ Then either follow the steps for building with or without Firebase.
|
||||
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.
|
||||
|
||||
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)
|
||||
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
if you're self-hosting the server. Then run:
|
||||
```
|
||||
# Remove Google dependencies (FCM)
|
||||
sed -i -e '/google-services/d' build.gradle
|
||||
sed -i -e '/google-services/d' app/build.gradle
|
||||
|
||||
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
||||
./gradlew assembleFdroidRelease
|
||||
|
||||
@@ -301,7 +310,7 @@ 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)
|
||||
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
* Then run:
|
||||
```
|
||||
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
|
||||
|
||||
@@ -4,6 +4,11 @@ There are a million ways to use ntfy, but here are some inspirations. I try to c
|
||||
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
|
||||
those out, too.
|
||||
|
||||
!!! info
|
||||
Many of these examples were contributed by ntfy users. If you have other examples of how you use ntfy, please
|
||||
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
|
||||
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
|
||||
|
||||
## A long process is done: backups, copying data, pipelines, ...
|
||||
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
|
||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||
@@ -98,7 +103,8 @@ One of my co-workers uses the following Ansible task to let him know when things
|
||||
```
|
||||
|
||||
## Watchtower notifications (shoutrrr)
|
||||
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
||||
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||
|
||||
Example docker-compose.yml:
|
||||
```yml
|
||||
@@ -122,7 +128,6 @@ GitHub have been hopeless. In case it ever becomes available, I want to know imm
|
||||
``` cron
|
||||
# Check github/ntfy user
|
||||
*/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)
|
||||
@@ -132,11 +137,10 @@ Some simple bash scripts to achieve this are kindly provided in [nickexyz's repo
|
||||
## 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>
|
||||
|
||||
```
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": "c956e688cc74ad8e",
|
||||
@@ -225,7 +229,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
|
||||
<details>
|
||||
<summary>Example: Send a picture (click to expand)</summary>
|
||||
|
||||
```
|
||||
``` json
|
||||
[
|
||||
{
|
||||
"id": "d135a13eadeb9d6d",
|
||||
@@ -341,8 +345,8 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
|
||||
|
||||
## Gatus service health check
|
||||
|
||||
An example for a custom alert with <a href="https://github.com/TwiN/gatus">Gatus</a>
|
||||
```
|
||||
An example for a custom alert with [Gatus](https://github.com/TwiN/gatus):
|
||||
``` yaml
|
||||
alerting:
|
||||
custom:
|
||||
url: "https://ntfy.sh"
|
||||
@@ -366,3 +370,18 @@ alerting:
|
||||
TRIGGERED: "warning"
|
||||
RESOLVED: "white_check_mark"
|
||||
```
|
||||
|
||||
## Jellyseerr/Overseerr webhook
|
||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||
|
||||
``` json
|
||||
{
|
||||
"topic": "requests",
|
||||
"title": "{{event}}",
|
||||
"message": "{{subject}}\n{{message}}\n\nRequested by: {{requestedBy_username}}\n\nStatus: {{media_status}}\nRequest Id: {{request_id}}",
|
||||
"priority": 4,
|
||||
"attach": "{{image}}",
|
||||
"click": "https://requests.example.com/{{media_type}}/{{media_tmdbid}}"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -13,50 +13,50 @@ The ntfy server comes as a statically linked binary and is shipped as tarball, d
|
||||
We support amd64, armv7 and arm64.
|
||||
|
||||
1. Install ntfy using one of the methods described below
|
||||
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
||||
2. Then (optionally) edit `/etc/ntfy/server.yml` for the server (Linux only, see [configuration](config.md) or [sample server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml))
|
||||
3. Or (optionally) create/edit `~/.config/ntfy/client.yml` (or `/etc/ntfy/client.yml`, see [sample client.yml](https://github.com/binwiederhier/ntfy/blob/main/client/client.yml))
|
||||
|
||||
To run the ntfy server, then just run `ntfy serve` (or `systemctl start ntfy` when using the deb/rpm).
|
||||
To send messages, use `ntfy publish`. To subscribe to topics, use `ntfy subscribe` (see [subscribing via CLI][subscribe/cli.md]
|
||||
for details).
|
||||
|
||||
## Binaries and packages
|
||||
## Linux binaries
|
||||
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
|
||||
deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.23.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.23.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.23.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.23.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.23.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.23.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.21.2_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.21.2_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.21.2_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.23.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.23.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.23.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -111,7 +111,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -119,7 +119,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -127,7 +127,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -137,28 +137,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.21.2/ntfy_1.21.2_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_1.23.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -176,6 +176,39 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_v1.23.0_macOS_all.tar.gz > ntfy_v1.23.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_v1.23.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_v1.23.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_v1.23.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
!!! info
|
||||
If there is a desire to install ntfy via [Homebrew](https://brew.sh/), please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. Also, you can build and run the
|
||||
ntfy server on macOS as well, though I don't officially support that. Check out the [build instructions](develop.md)
|
||||
for details.
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.23.0/ntfy_v1.23.0-next_windows_x86_64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
|
||||
!!! info
|
||||
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
be pretty straight forward to use.
|
||||
|
||||
203
docs/publish.md
203
docs/publish.md
@@ -38,7 +38,7 @@ Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/topic -Body "Backup successful 😀" -UseBasicParsing
|
||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -163,6 +163,138 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
||||
<figcaption>Urgent notification with tags and title</figcaption>
|
||||
</figure>
|
||||
|
||||
You can also do multi-line messages. Here's an example using a [click action](#click-action), an [action button](#action-buttons),
|
||||
an [external image attachment](#attach-file-from-a-url) and [email publishing](#e-mail-publishing):
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-H "Click: https://home.nest.com/" \
|
||||
-H "Attach: https://nest.com/view/yAxkasd.jpg" \
|
||||
-H "Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \
|
||||
-H "Email: phil@example.com" \
|
||||
-d "There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell." \
|
||||
ntfy.sh/mydoorbell
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--click="https://home.nest.com/" \
|
||||
--attach="https://nest.com/view/yAxkasd.jpg" \
|
||||
--actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true" \
|
||||
--email="phil@example.com" \
|
||||
mydoorbell \
|
||||
"There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell."
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mydoorbell HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Click: https://home.nest.com/
|
||||
Attach: https://nest.com/view/yAxkasd.jpg
|
||||
Actions: http, Open door, https://api.nest.com/open/yAxkasd, clear=true
|
||||
Email: phil@example.com
|
||||
|
||||
There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.sh/mydoorbell', {
|
||||
method: 'POST', // PUT works too
|
||||
headers: {
|
||||
'Click': 'https://home.nest.com/',
|
||||
'Attach': 'https://nest.com/view/yAxkasd.jpg',
|
||||
'Actions': 'http, Open door, https://api.nest.com/open/yAxkasd, clear=true',
|
||||
'Email': 'phil@example.com'
|
||||
},
|
||||
body: `There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.`,
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/mydoorbell",
|
||||
strings.NewReader(`There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.`))
|
||||
req.Header.Set("Click", "https://home.nest.com/")
|
||||
req.Header.Set("Attach", "https://nest.com/view/yAxkasd.jpg")
|
||||
req.Header.Set("Actions", "http, Open door, https://api.nest.com/open/yAxkasd, clear=true")
|
||||
req.Header.Set("Email", "phil@example.com")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mydoorbell"
|
||||
$headers = @{ Click="https://home.nest.com/"
|
||||
Attach="https://nest.com/view/yAxkasd.jpg"
|
||||
Actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true"
|
||||
Email="phil@example.com" }
|
||||
$body = @'
|
||||
There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.
|
||||
'@
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/mydoorbell",
|
||||
data="""There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.""".encode('utf-8'),
|
||||
headers={
|
||||
"Click": "https://home.nest.com/",
|
||||
"Attach": "https://nest.com/view/yAxkasd.jpg",
|
||||
"Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true",
|
||||
"Email": "phil@example.com"
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.sh/mydoorbell', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST', // PUT also works
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Click: https://home.nest.com/\r\n" .
|
||||
"Attach: https://nest.com/view/yAxkasd.jpg\r\n" .
|
||||
"Actions": "http, Open door, https://api.nest.com/open/yAxkasd, clear=true\r\n" .
|
||||
"Email": "phil@example.com\r\n",
|
||||
'content' => 'There\'s someone at the door. 🐶
|
||||
|
||||
Please check if it\'s a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
<figure markdown>
|
||||
{ width=500 }
|
||||
<figcaption>Notification using a click action, a user action, with an external image attachment and forwarded via email</figcaption>
|
||||
</figure>
|
||||
|
||||
## Message title
|
||||
The notification title is typically set to the topic short URL (e.g. `ntfy.sh/mytopic`). To override the title,
|
||||
you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||
@@ -850,27 +982,32 @@ To define actions using the `X-Actions` header (or any of its aliases: `Actions`
|
||||
<action1>, <label1>, paramN=... [; <action2>, <label2>, ...]
|
||||
```
|
||||
|
||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and `http` action.
|
||||
The format has **some limitations**: You cannot use `,` or `;` in any of the values, and depending on your language/library, UTF-8
|
||||
characters may not work. Use the [JSON array format](#using-a-json-array) instead to overcome these limitations.
|
||||
Multiple actions are separated by a semicolon (`;`), and key/value pairs are separated by commas (`,`). Values may be
|
||||
quoted with double quotes (`"`) or single quotes (`'`) if the value itself contains commas or semicolons.
|
||||
|
||||
The `action=` and `label=` prefix are optional in all actions, and the `url=` prefix is optional in the `view` and
|
||||
`http` action. The only limitation of this format is that depending on your language/library, UTF-8 characters may not
|
||||
work. If they don't, use the [JSON array format](#using-a-json-array) instead.
|
||||
|
||||
As an example, here's how you can create the above notification using this format. Refer to the [`view` action](#open-websiteapp) and
|
||||
[`http` action](#send-http-request) section for details on the specific actions:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
body='{"temperature": 65}'
|
||||
curl \
|
||||
-d "You left the house. Turn down the A/C?" \
|
||||
-H "Actions: view, Open portal, https://home.nest.com/, clear=true; \
|
||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
||||
ntfy.sh/myhome
|
||||
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||
ntfy.sh/myhome
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
body='{"temperature": 65}'
|
||||
ntfy publish \
|
||||
--actions="view, Open portal, https://home.nest.com/, clear=true; \
|
||||
http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" \
|
||||
http, Turn down, https://api.nest.com/, body='$body'" \
|
||||
myhome \
|
||||
"You left the house. Turn down the A/C?"
|
||||
```
|
||||
@@ -879,7 +1016,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
``` http
|
||||
POST /myhome HTTP/1.1
|
||||
Host: ntfy.sh
|
||||
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65
|
||||
Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{"temperature": 65}'
|
||||
|
||||
You left the house. Turn down the A/C?
|
||||
```
|
||||
@@ -890,7 +1027,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
method: 'POST',
|
||||
body: 'You left the house. Turn down the A/C?',
|
||||
headers: {
|
||||
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65'
|
||||
'Actions': 'view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body=\'{"temperature": 65}\''
|
||||
}
|
||||
})
|
||||
```
|
||||
@@ -898,14 +1035,14 @@ As an example, here's how you can create the above notification using this forma
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.sh/myhome", strings.NewReader("You left the house. Turn down the A/C?"))
|
||||
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65")
|
||||
req.Header.Set("Actions", "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/myhome"
|
||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" }
|
||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }
|
||||
$body = "You left the house. Turn down the A/C?"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
```
|
||||
@@ -914,7 +1051,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
``` python
|
||||
requests.post("https://ntfy.sh/myhome",
|
||||
data="You left the house. Turn down the A/C?",
|
||||
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65" })
|
||||
headers={ "Actions": "view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" })
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
@@ -924,7 +1061,7 @@ As an example, here's how you can create the above notification using this forma
|
||||
'method' => 'POST',
|
||||
'header' =>
|
||||
"Content-Type: text/plain\r\n" .
|
||||
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/device/XZ1D2, body=target_temp_f=65",
|
||||
"Actions: view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'",
|
||||
'content' => 'You left the house. Turn down the A/C?'
|
||||
]
|
||||
]));
|
||||
@@ -950,8 +1087,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}'
|
||||
@@ -970,8 +1107,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]' \
|
||||
myhome \
|
||||
@@ -996,8 +1133,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1020,8 +1157,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
action: "http",
|
||||
label: "Turn down",
|
||||
url: "https://api.nest.com/device/XZ1D2",
|
||||
body: "target_temp_f=65"
|
||||
url: "https://api.nest.com/",
|
||||
body: "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1046,8 +1183,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
}`
|
||||
@@ -1071,8 +1208,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
@{
|
||||
"action"="http",
|
||||
"label"="Turn down"
|
||||
"url"="https://api.nest.com/device/XZ1D2"
|
||||
"body"="target_temp_f=65"
|
||||
"url"="https://api.nest.com/"
|
||||
"body"="{\"temperature\": 65}"
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json
|
||||
@@ -1095,8 +1232,8 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
{
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"body": "target_temp_f=65"
|
||||
"url": "https://api.nest.com/",
|
||||
"body": "{\"temperature\": 65}"
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -1122,11 +1259,11 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
[
|
||||
"action": "http",
|
||||
"label": "Turn down",
|
||||
"url": "https://api.nest.com/device/XZ1D2",
|
||||
"url": "https://api.nest.com/",
|
||||
"headers": [
|
||||
"Authorization": "Bearer ..."
|
||||
],
|
||||
"body": "target_temp_f=65"
|
||||
"body": "{\"temperature\": 65}"
|
||||
]
|
||||
]
|
||||
])
|
||||
@@ -2367,9 +2504,11 @@ Here's a simple example:
|
||||
=== "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
|
||||
$credentials = 'username:password'
|
||||
$encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials))
|
||||
$headers = @{Authorization="Basic $encodedCredentials"}
|
||||
$message = "Look ma, with auth"
|
||||
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
|
||||
133
docs/releases.md
133
docs/releases.md
@@ -2,36 +2,137 @@
|
||||
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.12.0 (UNRELEASED)
|
||||
## ntfy server v1.23.0
|
||||
Released May 21, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
|
||||
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
|
||||
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
|
||||
* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))
|
||||
* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Typo in install instructions ([#252](https://github.com/binwiederhier/ntfy/pull/252)/[#251](https://github.com/binwiederhier/ntfy/issues/251), thanks to [@oddlama](https://github.com/oddlama))
|
||||
* Fix typo in private server example ([#262](https://github.com/binwiederhier/ntfy/pull/262), thanks to [@MayeulC](https://github.com/MayeulC))
|
||||
* [Examples](examples.md) for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) ([#264](https://github.com/binwiederhier/ntfy/pull/264), thanks to [@Fallenbagel](https://github.com/Fallenbagel))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Portuguese/Brazil (thanks to [@tiagotriques](https://hosted.weblate.org/user/tiagotriques/) and [@pireshenrique22](https://hosted.weblate.org/user/pireshenrique22/))
|
||||
|
||||
Thank you to the many translators, who helped translate the new strings so quickly. I am humbled and amazed by your help.
|
||||
|
||||
## ntfy Android app v1.13.0
|
||||
Released May 11, 2022
|
||||
|
||||
This release brings a slightly altered design for the detail view, featuring a card layout to make notifications more easily
|
||||
distinguishable from one another. It also ships per-topic settings that allow overriding minimum priority, auto delete threshold
|
||||
and custom icons. Aside from that, we've got tons of bug fixes as usual.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)
|
||||
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Restoring topics or settings from backup doesn't work ([#223](https://github.com/binwiederhier/ntfy/issues/223), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Fix app icon on old Android versions ([#128](https://github.com/binwiederhier/ntfy/issues/128), thanks to [@shadow00](https://github.com/shadow00) for reporting)
|
||||
* Fix races in UnifiedPush registration ([#230](https://github.com/binwiederhier/ntfy/issues/230), thanks to @Jakob for reporting)
|
||||
* Prevent view action from crashing the app ([#233](https://github.com/binwiederhier/ntfy/issues/233))
|
||||
* Prevent long topic names and icons from overlapping ([#240](https://github.com/binwiederhier/ntfy/issues/240), thanks to [@cmeis](https://github.com/cmeis) for reporting)
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Dutch (*incomplete*, thanks to [@diony](https://hosted.weblate.org/user/diony/))
|
||||
|
||||
**Thank you:**
|
||||
|
||||
Thanks to [@cmeis](https://github.com/cmeis), [@StoyanDimitrov](https://github.com/StoyanDimitrov), [@Fallenbagel](https://github.com/Fallenbagel) for testing, and
|
||||
to [@Joeharrison94](https://github.com/Joeharrison94) for the input. And thank you very much to all the translators for catching up so quickly.
|
||||
|
||||
## ntfy server v1.22.0
|
||||
Released May 7, 2022
|
||||
|
||||
This release makes the web app more accessible to people with disabilities, and introduces a "mark as read" icon in the web app.
|
||||
It also fixes a curious bug with WebSockets and Apache and makes the notification sounds in the web app a little quieter.
|
||||
|
||||
We've also improved the documentation a little and added translations for three more languages.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Make web app more accessible ([#217](https://github.com/binwiederhier/ntfy/issues/217))
|
||||
* Better parsing of the user actions, allowing quotes (no ticket)
|
||||
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
|
||||
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
|
||||
* Add "private browsing"-specific error message for Firefox/Safari ([#208](https://github.com/binwiederhier/ntfy/issues/208), thanks to [@julianfoad](https://github.com/julianfoad) for reporting)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Improved caddy configuration (no ticket, thanks to @Stnby)
|
||||
* Additional multi-line examples on the [publish page](https://ntfy.sh/docs/publish/) ([#234](https://github.com/binwiederhier/ntfy/pull/234), thanks to [@aTable](https://github.com/aTable))
|
||||
* Fixed PowerShell auth example to use UTF-8 ([#242](https://github.com/binwiederhier/ntfy/pull/242), thanks to [@SMAW](https://github.com/SMAW))
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||
* Hungarian (thanks to [@agocsdaniel](https://hosted.weblate.org/user/agocsdaniel/))
|
||||
|
||||
**Thanks for testing:**
|
||||
|
||||
Thanks to [@wunter8](https://github.com/wunter8) for testing.
|
||||
|
||||
## ntfy Android app v1.12.0
|
||||
Released Apr 25, 2022
|
||||
|
||||
The main feature in this Android release is [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons), a feature
|
||||
that allows users to add actions to the notifications. Actions can be to view a website or app, send a broadcast, or
|
||||
send a HTTP request.
|
||||
|
||||
We also added support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links), added three more
|
||||
languages and fixed a ton of bugs.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
thanks to [@mrherman](https://github.com/mrherman) for reporting)
|
||||
* Support for [ntfy:// deep links](https://ntfy.sh/docs/subscribe/phone/#ntfy-links) ([#20](https://github.com/binwiederhier/ntfy/issues/20), thanks
|
||||
to [@Copephobia](https://github.com/Copephobia) for reporting)
|
||||
* [Fastlane metadata](https://hosted.weblate.org/projects/ntfy/android-fastlane/) can now be translated too ([#198](https://github.com/binwiederhier/ntfy/issues/198),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
|
||||
* Error in sending test notification in different languages ([#209](https://github.com/binwiederhier/ntfy/issues/209),
|
||||
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
|
||||
* "[x] Instant delivery in doze mode" checkbox does not work properly ([#211](https://github.com/binwiederhier/ntfy/issues/211))
|
||||
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||
* Disallow "http" GET/HEAD actions with body ([#221](https://github.com/binwiederhier/ntfy/issues/221), thanks to
|
||||
[@cmeis](https://github.com/cmeis) for reporting)
|
||||
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
|
||||
* Action "view" with "clear=true" does not work on some phones ([#220](https://github.com/binwiederhier/ntfy/issues/220), thanks to
|
||||
[@cmeis](https://github.com/cmeis) for reporting)
|
||||
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
|
||||
* Do not group foreground service notification with others ([#219](https://github.com/binwiederhier/ntfy/issues/219), thanks to
|
||||
[@s-h-a-r-d](https://github.com/s-h-a-r-d) for reporting)
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Czech (thanks to [@waclaw66](https://hosted.weblate.org/user/waclaw66/))
|
||||
* French (thanks to [@nathanaelhoun](https://hosted.weblate.org/user/nathanaelhoun/))
|
||||
* Japanese (thanks to [@shak](https://hosted.weblate.org/user/shak/))
|
||||
* Russian (thanks to [@flamey](https://hosted.weblate.org/user/flamey/) and [@ilya.mikheev.coder](https://hosted.weblate.org/user/ilya.mikheev.coder/))
|
||||
|
||||
@@ -40,12 +141,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
Thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d) (aka @Shard), [@cmeis](https://github.com/cmeis),
|
||||
@poblabs, and everyone I forgot for testing.
|
||||
|
||||
-->
|
||||
|
||||
|
||||
## ntfy server v1.21.2
|
||||
Released Apr 24, 2022
|
||||
|
||||
In this release, the web app got translation support and was translated into 9 languages already 🇧🇬 🇩🇪 🇺🇸 🌎.
|
||||
It also re-adds support for ARMv6, and adds server-side support for Action Buttons. [Action Buttons](https://ntfy.sh/docs/publish/#action-buttons)
|
||||
is a feature that will be released in the Android app soon. It allows users to add actions to the notifications.
|
||||
Limited support is available in the web app.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Custom notification [action buttons](https://ntfy.sh/docs/publish/#action-buttons) ([#134](https://github.com/binwiederhier/ntfy/issues/134),
|
||||
|
||||
4
docs/static/css/extra.css
vendored
4
docs/static/css/extra.css
vendored
@@ -8,8 +8,8 @@
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.md-sidebar {
|
||||
width: 12.5rem !important;
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.md-typeset h4 {
|
||||
|
||||
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-notification-multiline.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -123,7 +123,7 @@ which will read the `subscribe` config from the config file. Please also check o
|
||||
|
||||
Here's an example config file that subscribes to three different topics, executing a different command for each of them:
|
||||
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
=== "~/.config/ntfy/client.yml (Linux)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
@@ -145,12 +145,42 @@ Here's an example config file that subscribes to three different topics, executi
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
=== "~/Library/Application Support/ntfy/client.yml (macOS)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
command: 'echo "Message received: $message"'
|
||||
- topic: alerts
|
||||
command: osascript -e "display notification \"$message\""
|
||||
if:
|
||||
priority: high,urgent
|
||||
- topic: calc
|
||||
command: open -a Calculator
|
||||
```
|
||||
|
||||
=== "%AppData%\ntfy\client.yml (Windows)"
|
||||
```yaml
|
||||
subscribe:
|
||||
- topic: echo-this
|
||||
command: 'echo Message received: %message%'
|
||||
- topic: alerts
|
||||
command: |
|
||||
notifu /m "%NTFY_MESSAGE%"
|
||||
exit 0
|
||||
if:
|
||||
priority: high,urgent
|
||||
- topic: calc
|
||||
command: calc
|
||||
```
|
||||
|
||||
In this example, when `ntfy subscribe --from-config` is executed:
|
||||
|
||||
* Messages to `echo-this` simply echos to standard out
|
||||
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html)
|
||||
* Messages to `calc` open the gnome calculator 😀 (*because, why not*)
|
||||
* Messages to `print-temp` execute an inline script and print the CPU temperature
|
||||
* Messages to `alerts` display as desktop notification for high priority messages using [notify-send](https://manpages.ubuntu.com/manpages/focal/man1/notify-send.1.html) (Linux),
|
||||
[notifu](https://www.paralint.com/projects/notifu/) (Windows) or `osascript` (macOS)
|
||||
* Messages to `calc` open the calculator 😀 (*because, why not*)
|
||||
* Messages to `print-temp` execute an inline script and print the CPU temperature (Linux version only)
|
||||
|
||||
I hope this shows how powerful this command is. Here's a short video that demonstrates the above example:
|
||||
|
||||
|
||||
32
go.mod
32
go.mod
@@ -4,50 +4,52 @@ go 1.17
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
||||
cloud.google.com/go/storage v1.22.0 // indirect
|
||||
cloud.google.com/go/storage v1.22.1 // indirect
|
||||
firebase.google.com/go v3.13.0+incompatible
|
||||
github.com/BurntSushi/toml v1.1.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // 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/mattn/go-sqlite3 v1.14.13
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/urfave/cli/v2 v2.4.7
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
|
||||
github.com/urfave/cli/v2 v2.7.1
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29
|
||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||
google.golang.org/api v0.75.0
|
||||
google.golang.org/api v0.80.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.101.0 // indirect
|
||||
cloud.google.com/go v0.101.1 // indirect
|
||||
cloud.google.com/go/compute v1.6.1 // indirect
|
||||
cloud.google.com/go/iam v0.3.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 // 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.3.0 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
|
||||
github.com/googleapis/go-type-adapters v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 // indirect
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 // indirect
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 // indirect
|
||||
google.golang.org/grpc v1.46.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd // indirect
|
||||
google.golang.org/grpc v1.46.2 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
)
|
||||
|
||||
65
go.sum
65
go.sum
@@ -27,8 +27,8 @@ cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
||||
cloud.google.com/go v0.101.0 h1:g+LL+JvpvdyGtcaD2xw2mSByE/6F9s471eJSoaysM84=
|
||||
cloud.google.com/go v0.101.0/go.mod h1:hEiddgDb77jDQ+I80tURYNJEnuwPzFU8awCFFRLKjW0=
|
||||
cloud.google.com/go v0.101.1 h1:3+/0TAm9JD/PyhkrDWQWi2L197h3euCsM+H+J4iYTR8=
|
||||
cloud.google.com/go v0.101.1/go.mod h1:55HwjsGW4CHD3JrNuMdZtSDsgTs0CuCB/bBTugD+7AA=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
@@ -56,8 +56,9 @@ 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.22.0 h1:NUV0NNp9nkBuW66BFRLuMgldN60C57ET3dhbwLIYio8=
|
||||
cloud.google.com/go/storage v1.22.0/go.mod h1:GbaLEoMqbVm6sx3Z0R++gSiBlgMv6yUi2q1DeGFKQgE=
|
||||
cloud.google.com/go/storage v1.22.1 h1:F6IlQJZrZM++apn9V5/VfS3gbTUYg98PS3EMQAzqtfg=
|
||||
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
|
||||
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=
|
||||
@@ -70,6 +71,8 @@ github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi
|
||||
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=
|
||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0 h1:R/qAiUxFT3mNgQaNqJe0IVznjKRNm23ohAIh9lgtlzc=
|
||||
github.com/antzucaro/matchr v0.0.0-20210222213004-b04723ef80f0/go.mod h1:v3ZDlfVAL1OrkKHbGSFFK60k0/7hruHPDq2XMs9Gu6U=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@@ -86,8 +89,9 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/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.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -160,8 +164,9 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||
@@ -185,13 +190,16 @@ github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
||||
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
|
||||
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
|
||||
github.com/googleapis/gax-go/v2 v2.4.0 h1:dS9eYAjhrE2RjmzYw2XAPvcXfmcQLtFEQWn0CR82awk=
|
||||
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
|
||||
github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
|
||||
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
@@ -209,8 +217,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.13 h1:1tj15ngiFfcZzii7yd82foL+ks+ouQcj8j/TPq3fk1I=
|
||||
github.com/mattn/go-sqlite3 v1.14.13/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=
|
||||
@@ -231,8 +239,8 @@ 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.7 h1:nUgKLTC/InVYwUx26HZUBGIBZaptiW97W8vVlhuYawo=
|
||||
github.com/urfave/cli/v2 v2.4.7/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
|
||||
github.com/urfave/cli/v2 v2.7.1 h1:DsAOFeI9T0vmUW4LiGR5mhuCIn5kqGIE4WMU2ytmH00=
|
||||
github.com/urfave/cli/v2 v2.7.1/go.mod h1:TYFbtzt/azQoJOrGH5mDfZtS0jIkl/OeFwlRWPR9KRM=
|
||||
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=
|
||||
@@ -252,8 +260,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
||||
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/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA=
|
||||
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898 h1:SLP7Q4Di66FONjDJbCYrCRrh97focO6sLogHO7/g8F0=
|
||||
golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/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=
|
||||
@@ -330,8 +338,9 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
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-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861 h1:yssD99+7tqHWO5Gwh81phT+67hg+KttniBr6UnEXOY8=
|
||||
golang.org/x/net v0.0.0-20220421235706-1d1ef9303861/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2 h1:NWy5+hlRbC7HK+PmcXVUmW1IMyFce7to56IUvhUFm7Y=
|
||||
golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
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=
|
||||
@@ -363,8 +372,9 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29 h1:w8s32wxx3sY+OjLlv9qltkLU5yvJzxjjgiHWLjdIcw4=
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -420,8 +430,10 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/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-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
|
||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
|
||||
@@ -496,8 +508,9 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
@@ -534,8 +547,11 @@ google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQ
|
||||
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.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
|
||||
google.golang.org/api v0.75.0 h1:0AYh/ae6l9TDUvIQrDw5QRpM100P6oHgD+o3dYHMzJg=
|
||||
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||
google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
|
||||
google.golang.org/api v0.80.0 h1:IQWaGVCYnsm4MO3hh+WtSXMzMzuyFx/fuR8qkN3A0Qo=
|
||||
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
|
||||
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=
|
||||
@@ -619,8 +635,12 @@ google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX
|
||||
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731 h1:nquqdM9+ps0JZcIiI70+tqoaIFS5Ql4ZuK8UXnz3HfE=
|
||||
google.golang.org/genproto v0.0.0-20220422154200-b37d22cd5731/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
|
||||
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd h1:e0TwkXOdbnH/1x5rc5MZ/VYyiZ4v+RdVfrGMqEwT68I=
|
||||
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
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=
|
||||
@@ -649,8 +669,9 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K
|
||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
google.golang.org/grpc v1.46.0 h1:oCjezcn6g6A75TGoKYBPgKmVBLexhYLM6MebdrPApP8=
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.46.2 h1:u+MLGgVf7vRdjEYZ8wDFhAVNmhkbJ5hmrA1LMWK1CAQ=
|
||||
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
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=
|
||||
|
||||
307
server/actions.go
Normal file
307
server/actions.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
actionIDLength = 10
|
||||
actionEOF = rune(0)
|
||||
actionsMax = 3
|
||||
)
|
||||
|
||||
const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
input string
|
||||
pos int
|
||||
}
|
||||
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
actions, err = parseActionsFromJSON(s)
|
||||
} else {
|
||||
actions, err = parseActionsFromSimple(s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add ID field, ensure correct uppercase/lowercase
|
||||
for i := range actions {
|
||||
actions[i].ID = util.RandomString(actionIDLength)
|
||||
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if len(actions) > actionsMax {
|
||||
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.InStringList(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, fmt.Errorf("JSON error: %w", err)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseActionsFromSimple parses the "simple" actions string (as described in
|
||||
// https://ntfy.sh/docs/publish/#action-buttons), into an array of actions.
|
||||
//
|
||||
// It can parse an actions string like this:
|
||||
// view, "Look ma, commas and \"quotes\" too", url=https://..; action=broadcast, ...
|
||||
//
|
||||
// It works by advancing the position ("pos") through the input string ("input").
|
||||
//
|
||||
// The parser is heavily inspired by https://go.dev/src/text/template/parse/lex.go (which
|
||||
// is described by Rob Pike in this video: https://www.youtube.com/watch?v=HxaD_trXwRE),
|
||||
// though it does not use state functions at all.
|
||||
//
|
||||
// Other resources:
|
||||
// https://adampresley.github.io/2015/04/12/writing-a-lexer-and-parser-in-go-part-1.html
|
||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
if !utf8.ValidString(s) {
|
||||
return nil, errors.New("invalid utf-8 string")
|
||||
}
|
||||
parser := &actionParser{
|
||||
pos: 0,
|
||||
input: s,
|
||||
}
|
||||
return parser.Parse()
|
||||
}
|
||||
|
||||
// Parse loops trough parseAction() until the end of the string is reached
|
||||
func (p *actionParser) Parse() ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
for !p.eof() {
|
||||
a, err := p.parseAction()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, a)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||
// when EOF or ";" is reached.
|
||||
func (p *actionParser) parseAction() (*action, error) {
|
||||
a := newAction()
|
||||
section := 0
|
||||
for {
|
||||
key, value, last, err := p.parseSection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := populateAction(a, section, key, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.slurpSpaces()
|
||||
if last {
|
||||
return a, nil
|
||||
}
|
||||
section++
|
||||
}
|
||||
}
|
||||
|
||||
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||
// pair to the action instance.
|
||||
func populateAction(newAction *action, section int, key, value string) error {
|
||||
// Auto-expand keys based on their index
|
||||
if key == "" && section == 0 {
|
||||
key = "action"
|
||||
} else if key == "" && section == 1 {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
}
|
||||
|
||||
// Validate
|
||||
if key == "" {
|
||||
return fmt.Errorf("term '%s' unknown", value)
|
||||
}
|
||||
|
||||
// Populate
|
||||
if strings.HasPrefix(key, "headers.") {
|
||||
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||
} else if strings.HasPrefix(key, "extras.") {
|
||||
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||
} else {
|
||||
switch strings.ToLower(key) {
|
||||
case "action":
|
||||
newAction.Action = value
|
||||
case "label":
|
||||
newAction.Label = value
|
||||
case "clear":
|
||||
lvalue := strings.ToLower(value)
|
||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
|
||||
}
|
||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||
case "url":
|
||||
newAction.URL = value
|
||||
case "method":
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
default:
|
||||
return fmt.Errorf("key '%s' unknown", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseSection parses a section ("key=value") and returns a key/value pair. It terminates
|
||||
// when EOF or "," is reached.
|
||||
func (p *actionParser) parseSection() (key string, value string, last bool, err error) {
|
||||
p.slurpSpaces()
|
||||
key = p.parseKey()
|
||||
r, w := p.peek()
|
||||
if isSectionEnd(r) {
|
||||
p.pos += w
|
||||
last = isLastSection(r)
|
||||
return
|
||||
} else if r == '"' || r == '\'' {
|
||||
value, last, err = p.parseQuotedValue(r)
|
||||
return
|
||||
}
|
||||
value, last = p.parseValue()
|
||||
return
|
||||
}
|
||||
|
||||
// parseKey uses a regex to determine whether the current position is a key definition ("key =")
|
||||
// and returns the key if it is, or an empty string otherwise.
|
||||
func (p *actionParser) parseKey() string {
|
||||
matches := actionsKeyRegex.FindStringSubmatch(p.input[p.pos:])
|
||||
if len(matches) == 2 {
|
||||
p.pos += len(matches[0])
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseValue reads the input until EOF, "," or ";" and returns the value string. Unlike parseQuotedValue,
|
||||
// this function does not support "," or ";" in the value itself, and spaces in the beginning and end of the
|
||||
// string are trimmed.
|
||||
func (p *actionParser) parseValue() (value string, last bool) {
|
||||
start := p.pos
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if isSectionEnd(r) {
|
||||
last = isLastSection(r)
|
||||
value = strings.TrimSpace(p.input[start:p.pos])
|
||||
p.pos += w
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuotedValue reads the input until it finds an unescaped end quote character ("), and then
|
||||
// advances the position beyond the section end. It supports quoting strings using backslash (\).
|
||||
func (p *actionParser) parseQuotedValue(quote rune) (value string, last bool, err error) {
|
||||
p.pos++
|
||||
start := p.pos
|
||||
var prev rune
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if r == actionEOF {
|
||||
err = fmt.Errorf("unexpected end of input, quote started at position %d", start)
|
||||
return
|
||||
} else if r == quote && prev != '\\' {
|
||||
value = strings.ReplaceAll(p.input[start:p.pos], "\\"+string(quote), string(quote)) // \" -> "
|
||||
p.pos += w
|
||||
|
||||
// Advance until section end (after "," or ";")
|
||||
p.slurpSpaces()
|
||||
r, w := p.peek()
|
||||
last = isLastSection(r)
|
||||
if !isSectionEnd(r) {
|
||||
err = fmt.Errorf("unexpected character '%c' at position %d", r, p.pos)
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
return
|
||||
}
|
||||
prev = r
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// slurpSpaces reads all space characters and advances the position
|
||||
func (p *actionParser) slurpSpaces() {
|
||||
for {
|
||||
r, w := p.peek()
|
||||
if r == actionEOF || !isSpace(r) {
|
||||
return
|
||||
}
|
||||
p.pos += w
|
||||
}
|
||||
}
|
||||
|
||||
// peek returns the next run and its width
|
||||
func (p *actionParser) peek() (rune, int) {
|
||||
if p.eof() {
|
||||
return actionEOF, 0
|
||||
}
|
||||
return utf8.DecodeRuneInString(p.input[p.pos:])
|
||||
}
|
||||
|
||||
// eof returns true if the end of the input has been reached
|
||||
func (p *actionParser) eof() bool {
|
||||
return p.pos >= len(p.input)
|
||||
}
|
||||
|
||||
func isSpace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||
}
|
||||
|
||||
func isSectionEnd(r rune) bool {
|
||||
return r == actionEOF || r == ';' || r == ','
|
||||
}
|
||||
|
||||
func isLastSection(r rune) bool {
|
||||
return r == actionEOF || r == ';'
|
||||
}
|
||||
176
server/actions_test.go
Normal file
176
server/actions_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
// Basic test
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// JSON
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
// Other params
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "PUT", actions[0].Method)
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
// Extras with underscores
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
require.Equal(t, "Do a thing", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Extras))
|
||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Send request", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Headers))
|
||||
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
|
||||
// Quotes
|
||||
actions, err = parseActions(`action=http, "Look ma, \"quotes\"; and semicolons", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Look ma, "quotes"; and semicolons`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes
|
||||
actions, err = parseActions(`action=http, '"quotes" and \'single quotes\'', url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `"quotes" and 'single quotes'`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Single quotes (JSON)
|
||||
actions, err = parseActions(`action=http, Post it, url=http://example.com, body='{"temperature": 65}'`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Post it", actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
require.Equal(t, `{"temperature": 65}`, actions[0].Body)
|
||||
|
||||
// Out of order
|
||||
actions, err = parseActions(`label="Out of order!" , action="http", url=http://example.com`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Out of order!`, actions[0].Label)
|
||||
require.Equal(t, `http://example.com`, actions[0].URL)
|
||||
|
||||
// Spaces
|
||||
actions, err = parseActions(`action = http, label = 'this is a label', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `this is a label`, actions[0].Label)
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Non-ASCII
|
||||
actions, err = parseActions(`action = http, 'Кохайтеся а не воюйте, 💙🫤', url = "http://google.com"`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Кохайтеся а не воюйте, 💙🫤`, actions[0].Label)
|
||||
require.Equal(t, `http://google.com`, actions[0].URL)
|
||||
|
||||
// Multiple actions, awkward spacing
|
||||
actions, err = parseActions(`http , 'Make love, not war 💙🫤' , https://ntfy.sh ; view, " yo ", https://x.org, clear=true`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, `Make love, not war 💙🫤`, actions[0].Label)
|
||||
require.Equal(t, `https://ntfy.sh`, actions[0].URL)
|
||||
require.Equal(t, false, actions[0].Clear)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, " yo ", actions[1].Label)
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
|
||||
_, err = parseActions(`label="", action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label=, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "parameter 'label' is required")
|
||||
|
||||
_, err = parseActions(`label="xx", action="http", url=http://example.com, what is this anyway`)
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
|
||||
_, err = parseActions(`action=http, label="omg the end quote is missing`)
|
||||
require.EqualError(t, err, "unexpected end of input, quote started at position 20")
|
||||
|
||||
_, err = parseActions(`;;;;`)
|
||||
require.EqualError(t, err, "only 3 actions allowed")
|
||||
|
||||
_, err = parseActions(`,,,,,,;;`)
|
||||
require.EqualError(t, err, "term '' unknown")
|
||||
|
||||
_, err = parseActions(`''";,;"`)
|
||||
require.EqualError(t, err, "unexpected character '\"' at position 2")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'url' is required for action 'http'")
|
||||
|
||||
_, err = parseActions(`action=http, label=a label, url=http://ntfy.sh, method=HEAD, body=somebody`)
|
||||
require.EqualError(t, err, "parameter 'body' cannot be set if method is HEAD")
|
||||
|
||||
_, err = parseActions(`[ invalid json ]`)
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
_, err = parseActions(`http, label, http://x.org, clear=x`)
|
||||
require.EqualError(t, err, "parameter 'clear' cannot be 'x', only boolean values are allowed (true/yes/1/false/no/0)")
|
||||
|
||||
}
|
||||
@@ -88,6 +88,7 @@ type Config struct {
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
BehindProxy bool
|
||||
EnableWeb bool
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -126,5 +127,6 @@ func NewConfig() *Config {
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
BehindProxy: false,
|
||||
EnableWeb: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
@@ -28,6 +23,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
@@ -263,23 +264,23 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||
return s.handleHome(w, r)
|
||||
return s.ensureWebEnabled(s.handleHome)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
|
||||
return s.handleExample(w, r)
|
||||
return s.ensureWebEnabled(s.handleExample)(w, r, v)
|
||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||
return s.handleEmpty(w, r, v)
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.handleWebConfig(w, r)
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} 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)
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.handleDocs(w, r)
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
|
||||
return s.limitRequests(s.handleFile)(w, r, v)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
|
||||
} 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) {
|
||||
@@ -297,21 +298,21 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
} 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)) {
|
||||
return s.handleTopic(w, r)
|
||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||
}
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.config.WebRootIsApp {
|
||||
r.URL.Path = webAppIndex
|
||||
} else {
|
||||
r.URL.Path = webHomeIndex
|
||||
}
|
||||
return s.handleStatic(w, r)
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
|
||||
if unifiedpush {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -320,7 +321,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r)
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
@@ -334,12 +335,12 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
|
||||
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
_, err := io.WriteString(w, exampleSource)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
appRoot := "/"
|
||||
if !s.config.WebRootIsApp {
|
||||
appRoot = "/app"
|
||||
@@ -367,13 +368,13 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -539,7 +540,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, err // wrapped errHTTPBadRequestActionsInvalid
|
||||
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
@@ -739,7 +740,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
|
||||
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Header.Get("Upgrade") != "websocket" {
|
||||
if strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
||||
return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
|
||||
}
|
||||
if err := v.SubscriptionAllowed(); err != nil {
|
||||
@@ -904,7 +905,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
||||
return sinceNoMessages, errHTTPBadRequestSinceInvalid
|
||||
}
|
||||
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
|
||||
@@ -1118,6 +1119,15 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if !s.config.EnableWeb {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
# ntfy server config file
|
||||
#
|
||||
# Please refer to the documentation at https://ntfy.sh/docs/config/ for details.
|
||||
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
|
||||
|
||||
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
|
||||
@@ -127,7 +130,8 @@
|
||||
# manager-interval: "1m"
|
||||
|
||||
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||
# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
|
||||
# web app. If you self-host, you don't want to change this.
|
||||
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
|
||||
#
|
||||
# web-root: app
|
||||
|
||||
|
||||
@@ -3,16 +3,18 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
firebase "firebase.google.com/go"
|
||||
"firebase.google.com/go/messaging"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/auth"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
fcmMessageLimit = 4000
|
||||
fcmMessageLimit = 4000
|
||||
fcmApnsBodyMessageLimit = 100
|
||||
)
|
||||
|
||||
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
|
||||
@@ -34,6 +36,20 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||
return m
|
||||
}
|
||||
|
||||
// maybeTruncateAPNSBodyMessage truncates the body for APNS.
|
||||
//
|
||||
// The "body" of the push notification can contain the entire message, which would count doubly for the overall length
|
||||
// of the APNS payload. I set a limit of 100 characters before truncating the notification "body" with ellipsis.
|
||||
// The message would not be changed (unless truncated for being too long). Note: if the payload is too large (>4KB),
|
||||
// APNS will simply reject / discard the notification, meaning it will never arrive on the iOS device.
|
||||
func maybeTruncateAPNSBodyMessage(s string) string {
|
||||
if len(s) >= fcmApnsBodyMessageLimit {
|
||||
over := len(s) - fcmApnsBodyMessageLimit + 3 // len("...")
|
||||
return s[:len(s)-over] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||
if err != nil {
|
||||
@@ -55,6 +71,7 @@ func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subsc
|
||||
|
||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||
var apnsConfig *messaging.APNSConfig
|
||||
switch m.Event {
|
||||
case keepaliveEvent, openEvent:
|
||||
data = map[string]string{
|
||||
@@ -95,6 +112,22 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
||||
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
|
||||
data["attachment_url"] = m.Attachment.URL
|
||||
}
|
||||
apnsData := make(map[string]interface{})
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
}
|
||||
apnsConfig = &messaging.APNSConfig{
|
||||
Payload: &messaging.APNSPayload{
|
||||
CustomData: apnsData,
|
||||
Aps: &messaging.Aps{
|
||||
MutableContent: true,
|
||||
Alert: &messaging.ApsAlert{
|
||||
Title: m.Title,
|
||||
Body: maybeTruncateAPNSBodyMessage(m.Message),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// If anonymous read for a topic is not allowed, we cannot send the message along
|
||||
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||
@@ -116,5 +149,6 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
||||
Topic: m.Topic,
|
||||
Data: data,
|
||||
Android: androidConfig,
|
||||
APNS: apnsConfig,
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,6 +15,10 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func TestServer_PublishAndPoll(t *testing.T) {
|
||||
@@ -162,6 +163,40 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
require.Contains(t, rr.Body.String(), "</html>")
|
||||
}
|
||||
|
||||
func TestServer_WebEnabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.EnableWeb = false
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
conf2.EnableWeb = true
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = "" // Disable attachments
|
||||
@@ -1303,7 +1338,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
|
||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
||||
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
|
||||
filename := filepath.Join(t.TempDir(), "firebase.json")
|
||||
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600))
|
||||
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0o600))
|
||||
return filename
|
||||
}
|
||||
t.SkipNow()
|
||||
|
||||
@@ -56,6 +56,13 @@ type action struct {
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
}
|
||||
|
||||
func newAction() *action {
|
||||
return &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
|
||||
107
server/util.go
107
server/util.go
@@ -1,17 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
actionIDLength = 10
|
||||
actionsMax = 3
|
||||
)
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
value := strings.ToLower(readParam(r, names...))
|
||||
if value == "" {
|
||||
@@ -47,103 +40,3 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
actions, err = parseActionsFromJSON(s)
|
||||
} else {
|
||||
actions, err = parseActionsFromSimple(s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add ID field, ensure correct uppercase/lowercase
|
||||
for i := range actions {
|
||||
actions[i].ID = util.RandomString(actionIDLength)
|
||||
actions[i].Action = strings.ToLower(actions[i].Action)
|
||||
actions[i].Method = strings.ToUpper(actions[i].Method)
|
||||
}
|
||||
|
||||
// Validate
|
||||
if len(actions) > actionsMax {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "only %d actions allowed", actionsMax)
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.InStringList([]string{"view", "broadcast", "http"}, action.Action) {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action '%s' unknown", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'label' is required")
|
||||
} else if util.InStringList([]string{"view", "http"}, action.Action) && action.URL == "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if action.Action == "http" && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
}
|
||||
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
rawActions := util.SplitNoEmpty(s, ";")
|
||||
for _, rawAction := range rawActions {
|
||||
newAction := &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
parts := util.SplitNoEmpty(rawAction, ",")
|
||||
if len(parts) < 3 {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "action requires at least keys 'action', 'label' and one parameter: %s", rawAction)
|
||||
}
|
||||
for i, part := range parts {
|
||||
key, value := util.SplitKV(part, "=")
|
||||
if key == "" && i == 0 {
|
||||
newAction.Action = value
|
||||
} else if key == "" && i == 1 {
|
||||
newAction.Label = value
|
||||
} else if key == "" && util.InStringList([]string{"view", "http"}, newAction.Action) && i == 2 {
|
||||
newAction.URL = value
|
||||
} else if strings.HasPrefix(key, "headers.") {
|
||||
newAction.Headers[strings.TrimPrefix(key, "headers.")] = value
|
||||
} else if strings.HasPrefix(key, "extras.") {
|
||||
newAction.Extras[strings.TrimPrefix(key, "extras.")] = value
|
||||
} else if key != "" {
|
||||
switch strings.ToLower(key) {
|
||||
case "action":
|
||||
newAction.Action = value
|
||||
case "label":
|
||||
newAction.Label = value
|
||||
case "clear":
|
||||
lvalue := strings.ToLower(value)
|
||||
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "'clear=%s' not allowed", value)
|
||||
}
|
||||
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"
|
||||
case "url":
|
||||
newAction.URL = value
|
||||
case "method":
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
default:
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "key '%s' unknown", key)
|
||||
}
|
||||
} else {
|
||||
return nil, wrapErrHTTP(errHTTPBadRequestActionsInvalid, "unknown term '%s'", part)
|
||||
}
|
||||
}
|
||||
actions = append(actions, newAction)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
@@ -27,56 +27,3 @@ func TestReadBoolParam(t *testing.T) {
|
||||
require.Equal(t, false, up)
|
||||
require.Equal(t, true, firebase)
|
||||
}
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
actions, err := parseActions("[]")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, actions)
|
||||
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open; view, Show portal, https://door.lan")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
actions, err = parseActions(`[{"action":"http","label":"Open door","url":"https://door.lan/open"}, {"action":"view","label":"Show portal","url":"https://door.lan"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "view", actions[1].Action)
|
||||
require.Equal(t, "Show portal", actions[1].Label)
|
||||
require.Equal(t, "https://door.lan", actions[1].URL)
|
||||
|
||||
actions, err = parseActions("action=http, label=Open door, url=https://door.lan/open, body=this is a body, method=PUT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Open door", actions[0].Label)
|
||||
require.Equal(t, "https://door.lan/open", actions[0].URL)
|
||||
require.Equal(t, "PUT", actions[0].Method)
|
||||
require.Equal(t, "this is a body", actions[0].Body)
|
||||
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, extras.command=some command, extras.some_param=a parameter")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
require.Equal(t, "Do a thing", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Extras))
|
||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "http", actions[0].Action)
|
||||
require.Equal(t, "Send request", actions[0].Label)
|
||||
require.Equal(t, 2, len(actions[0].Headers))
|
||||
require.Equal(t, "application/json", actions[0].Headers["Content-Type"])
|
||||
require.Equal(t, "Basic sdasffsf", actions[0].Headers["Authorization"])
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
sw := NewContentTypeWriter(rr, "")
|
||||
randomBytes := make([]byte, 199)
|
||||
rand.Read(randomBytes)
|
||||
rand.Read(randomBytes[5:]) // Start at an offset; the test kept failing randomly because it hit random magic strings
|
||||
sw.Write(randomBytes)
|
||||
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
@@ -183,11 +183,6 @@ func PriorityString(priority int) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// ExpandHome replaces "~" with the user's home directory
|
||||
func ExpandHome(path string) string {
|
||||
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
||||
}
|
||||
|
||||
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
||||
func ShortTopicURL(s string) string {
|
||||
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
||||
|
||||
@@ -3,7 +3,6 @@ package util
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -75,14 +74,6 @@ func TestSplitNoEmpty(t *testing.T) {
|
||||
require.Equal(t, []string{"tag1", "tag2"}, SplitNoEmpty("tag1,tag2,", ","))
|
||||
}
|
||||
|
||||
func TestExpandHome_WithTilde(t *testing.T) {
|
||||
require.Equal(t, os.Getenv("HOME")+"/this/is/a/path", ExpandHome("~/this/is/a/path"))
|
||||
}
|
||||
|
||||
func TestExpandHome_NoTilde(t *testing.T) {
|
||||
require.Equal(t, "/this/is/an/absolute/path", ExpandHome("/this/is/an/absolute/path"))
|
||||
}
|
||||
|
||||
func TestParsePriority(t *testing.T) {
|
||||
priorities := []string{"", "1", "2", "3", "4", "5", "min", "LOW", " default ", "HIgh", "max", "urgent"}
|
||||
expected := []int{0, 1, 2, 3, 4, 5, 1, 2, 3, 4, 5, 5}
|
||||
|
||||
4033
web/package-lock.json
generated
4033
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
"publish_dialog_progress_uploading": "Изпращане…",
|
||||
"publish_dialog_progress_uploading_detail": "Изпращане {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_message_published": "Известието е публикувано",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението и квотата от {{fileSizeLimit}}, оставащи {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл и квотата, остават {{remainingBytes}}",
|
||||
"publish_dialog_message_label": "Съобщение",
|
||||
"publish_dialog_message_placeholder": "Въведете съобщение",
|
||||
"publish_dialog_other_features": "Други възможности:",
|
||||
@@ -43,7 +43,7 @@
|
||||
"message_bar_type_message": "Въведете съобщение",
|
||||
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
||||
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
||||
"notifications_attachment_link_expired": "препратката за изтегляне е невалидна",
|
||||
"notifications_attachment_link_expired": "препратката за изтегляне е с изтекла давност",
|
||||
"nav_button_settings": "Настройки",
|
||||
"nav_button_documentation": "Ръководство",
|
||||
"nav_button_subscribe": "Абониране за тема",
|
||||
@@ -59,27 +59,27 @@
|
||||
"notifications_actions_open_url_title": "Към {{url}}",
|
||||
"notifications_click_copy_url_button": "Копиране на препратка",
|
||||
"notifications_click_open_button": "Отваряне",
|
||||
"notifications_click_copy_url_title": "Копира препратката в междинната памет",
|
||||
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
||||
"notifications_none_for_topic_title": "Липсват известия в темата",
|
||||
"notifications_none_for_any_title": "Липсват известия",
|
||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто изпратете PUT или POST към адреса ѝ.",
|
||||
"notifications_none_for_any_description": "За да изпратите известия в тема, просто изпратете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получавате тук.",
|
||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема, просто направете PUT или POST към адреса ѝ.",
|
||||
"notifications_none_for_any_description": "За да изпратите известия в тема, просто направете PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като изпратите съобщения чрез метода PUT или POST ще ги получите тук.",
|
||||
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
||||
"publish_dialog_priority_min": "Мин. приоритет",
|
||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
||||
"publish_dialog_base_url_label": "Адрес на услугата",
|
||||
"publish_dialog_base_url_placeholder": "Адрес на услугата, напр. https://example.com",
|
||||
"publish_dialog_topic_placeholder": "Име на темата, напр. phils_alerts",
|
||||
"publish_dialog_priority_low": "Нисък приоритет",
|
||||
"publish_dialog_attachment_limits_quota_reached": "надвишава ограничението, оставащи {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "надвишава квотата, остават {{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "Висок приоритет",
|
||||
"publish_dialog_priority_default": "Подразбиран приоритет",
|
||||
"publish_dialog_title_placeholder": "Заглавие на известието, напр. Предупреждение за диска",
|
||||
"publish_dialog_tags_label": "Етикети",
|
||||
"publish_dialog_email_label": "Адрес на електронна поща",
|
||||
"publish_dialog_priority_max": "Макс. приоритет",
|
||||
"publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. внимание, диск",
|
||||
"publish_dialog_tags_placeholder": "Разделени със запетая етикети, напр. warning, srv1-backup",
|
||||
"publish_dialog_click_label": "Адрес",
|
||||
"publish_dialog_topic_label": "Име на темата",
|
||||
"publish_dialog_title_label": "Заглавие",
|
||||
@@ -130,14 +130,14 @@
|
||||
"prefs_users_dialog_username_label": "Потребител, напр. phil",
|
||||
"prefs_users_dialog_button_add": "Добавяне",
|
||||
"error_boundary_title": "О, не, ntfy се срина",
|
||||
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink>, или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_stack_trace": "Следа от стека",
|
||||
"error_boundary_gathering_info": "Събиране на допълнителна информация…",
|
||||
"notifications_loading": "Зареждане на известия…",
|
||||
"error_boundary_button_copy_stack_trace": "Копиране на следата от стека",
|
||||
"prefs_users_description": "Добавяйте и премахвайте потребители за защитените теми. Имайте предвид, че потребителското име и паролата се съхраняват в местната памет на мрежовия четец.",
|
||||
"prefs_notifications_sound_description_none": "Известията не са съпроводени със звук",
|
||||
"prefs_notifications_sound_description_some": "Известията са съпроводени със звука „{{sound}}“",
|
||||
"prefs_notifications_sound_description_some": "При пристигане известията са съпроводени от звука „{{sound}}“",
|
||||
"prefs_notifications_delete_after_never_description": "Известията никога не се премахват автоматично",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Известията се премахват автоматично след три часа",
|
||||
"priority_min": "минимален",
|
||||
@@ -149,6 +149,43 @@
|
||||
"prefs_notifications_delete_after_one_day_description": "Известията се премахват автоматично след един ден",
|
||||
"prefs_notifications_min_priority_description_max": "Показват се известията с приоритет 5 (най-висок)",
|
||||
"prefs_notifications_delete_after_one_month_description": "Известията се премахват автоматично след един месец",
|
||||
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета им",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок"
|
||||
"prefs_notifications_min_priority_description_any": "Показват се всички известия, независимо от приоритета",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показват се известията с приоритет {{number}} ({{name}}) или по-висок",
|
||||
"notifications_actions_http_request_title": "Изпращане на HTTP {{method}} до {{url}}",
|
||||
"notifications_actions_not_supported": "Действието не се поддържа от приложението за интернет",
|
||||
"action_bar_show_menu": "Показване на менюто",
|
||||
"action_bar_logo_alt": "Логотип на ntfy",
|
||||
"action_bar_toggle_mute": "Заглушаване или пускне на известията",
|
||||
"action_bar_toggle_action_menu": "Отваряне или затваряне на менюто с действията",
|
||||
"nav_button_muted": "Известията са заглушени",
|
||||
"notifications_list": "Списък с известия",
|
||||
"notifications_list_item": "Известие",
|
||||
"notifications_delete": "Изтриване",
|
||||
"notifications_mark_read": "Отбелязване като прочетено",
|
||||
"nav_button_connecting": "свързване",
|
||||
"message_bar_show_dialog": "Показване на диалога за публикуване",
|
||||
"message_bar_publish": "Публикуване на съобщение",
|
||||
"notifications_priority_x": "Приоритет {{priority}}",
|
||||
"notifications_new_indicator": "Ново известие",
|
||||
"notifications_attachment_image": "Прикачено изображение",
|
||||
"notifications_attachment_file_image": "файл на изображение",
|
||||
"notifications_attachment_file_video": "файл на видео",
|
||||
"notifications_attachment_file_audio": "файл на аудио",
|
||||
"notifications_attachment_file_app": "Инсталационен файл на приложение за Android",
|
||||
"notifications_attachment_file_document": "друг документ",
|
||||
"publish_dialog_emoji_picker_show": "Избор на емоция",
|
||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||
"publish_dialog_click_reset": "Премахване на адрес",
|
||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
|
||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||
"prefs_notifications_sound_play": "Възпроизвеждане на избрания звук",
|
||||
"publish_dialog_attach_reset": "Премахване на адреса на файла за прикачане",
|
||||
"prefs_users_delete_button": "Премахване на потребител",
|
||||
"prefs_users_table": "Таблица с потребители",
|
||||
"prefs_users_edit_button": "Промяна на потребител",
|
||||
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
|
||||
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
|
||||
191
web/public/static/langs/cs.json
Normal file
191
web/public/static/langs/cs.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"action_bar_settings": "Nastavení",
|
||||
"action_bar_send_test_notification": "Odeslání testovacího oznámení",
|
||||
"action_bar_clear_notifications": "Vymazat všechna oznámení",
|
||||
"action_bar_unsubscribe": "Odhlásit odběr",
|
||||
"message_bar_type_message": "Zde napište zprávu",
|
||||
"message_bar_error_publishing": "Chyba při odesílání oznámení",
|
||||
"nav_topics_title": "Odebíraná témata",
|
||||
"nav_button_all_notifications": "Všechna oznámení",
|
||||
"nav_button_settings": "Nastavení",
|
||||
"nav_button_documentation": "Dokumentace",
|
||||
"nav_button_publish_message": "Odeslat oznámení",
|
||||
"nav_button_subscribe": "Přihlásit se k odběru tématu",
|
||||
"alert_grant_title": "Oznámení jsou zakázána",
|
||||
"alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
|
||||
"alert_grant_button": "Udělit nyní",
|
||||
"alert_not_supported_title": "Oznámení nejsou podporována",
|
||||
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
|
||||
"notifications_copied_to_clipboard": "Zkopírováno do schránky",
|
||||
"notifications_tags": "Značky",
|
||||
"notifications_attachment_copy_url_title": "Kopírovat URL přílohy do schránky",
|
||||
"notifications_attachment_copy_url_button": "Kopírovat URL",
|
||||
"notifications_attachment_open_title": "Přejít na {{url}}",
|
||||
"notifications_attachment_open_button": "Otevřít přílohu",
|
||||
"notifications_attachment_link_expires": "platnost odkazu končí {{date}}",
|
||||
"notifications_attachment_link_expired": "platnost odkazu ke stažení vypršela",
|
||||
"notifications_click_copy_url_title": "Kopírovat URL odkazu do schránky",
|
||||
"notifications_click_copy_url_button": "Kopírovat odkaz",
|
||||
"notifications_click_open_button": "Otevřít odkaz",
|
||||
"notifications_none_for_topic_title": "K tomuto tématu jste zatím neobdrželi žádné oznámení.",
|
||||
"notifications_none_for_topic_description": "Pro odeslání oznámení k tomuto tématu, odešlete PUT nebo POST požadavek na URL tématu.",
|
||||
"notifications_example": "Příklad",
|
||||
"publish_dialog_base_url_placeholder": "URL služby, např. https://example.com",
|
||||
"publish_dialog_topic_label": "Název tématu",
|
||||
"publish_dialog_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"publish_dialog_priority_default": "Výchozí priorita",
|
||||
"publish_dialog_priority_high": "Vysoká priorita",
|
||||
"publish_dialog_priority_max": "Nevyšší priorita",
|
||||
"publish_dialog_base_url_label": "URL služby",
|
||||
"prefs_users_dialog_password_label": "Heslo",
|
||||
"prefs_users_dialog_title_add": "Přidat uživatele",
|
||||
"prefs_users_dialog_title_edit": "Upravit uživatele",
|
||||
"prefs_users_dialog_base_url_label": "URL služby, např. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Uživatelské jméno, např. phil",
|
||||
"notifications_actions_open_url_title": "Přejít na {{url}}",
|
||||
"notifications_none_for_any_title": "Neobdrželi jste žádná oznámení.",
|
||||
"notifications_none_for_any_description": "Pro odeslání oznámení k tématu stačí na URL tématu odeslat PUT nebo POST požadavek. Zde je příklad s použitím jednoho z vašich témat.",
|
||||
"notifications_no_subscriptions_description": "Kliknutím na \"{{linktext}}\" vytvoříte téma nebo se k němu přihlásíte. Poté můžete odesílat zprávy prostřednictvím PUT nebo POST požadavků a zde budete dostávat oznámení.",
|
||||
"notifications_more_details": "Další informace naleznete na <websiteLink>webových stránkách</websiteLink> nebo v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_title_topic": "Odeslat do {{téma}}",
|
||||
"publish_dialog_title_no_topic": "Odeslat oznámení",
|
||||
"publish_dialog_progress_uploading": "Nahrávání …",
|
||||
"publish_dialog_message_published": "Oznámení odesláno",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "překračuje {{fileSizeLimit}} limit souboru a kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_attachment_limits_quota_reached": "překračuje kvótu, {{remainingBytes}} zbývá",
|
||||
"publish_dialog_priority_min": "Nejnižší priorita",
|
||||
"publish_dialog_title_label": "Název",
|
||||
"publish_dialog_title_placeholder": "Název oznámení, např. Upozornění na volné místo na disku",
|
||||
"publish_dialog_message_placeholder": "Zde napište zprávu",
|
||||
"publish_dialog_tags_label": "Značky",
|
||||
"publish_dialog_priority_label": "Priorita",
|
||||
"publish_dialog_click_label": "Klikněte na URL",
|
||||
"publish_dialog_click_placeholder": "Adresa URL, která se otevře po kliknutí na oznámení",
|
||||
"publish_dialog_email_label": "E-mail",
|
||||
"publish_dialog_email_placeholder": "Adresa pro odeslání oznámení, např. phil@example.com",
|
||||
"publish_dialog_attach_label": "URL přílohy",
|
||||
"publish_dialog_filename_label": "Název souboru",
|
||||
"publish_dialog_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_delay_label": "Zpoždění",
|
||||
"publish_dialog_delay_placeholder": "Zpožděné doručení, např. {{unixTimestamp}}, {{relativeTime}} nebo \"{{naturalLanguage}}\". (pouze v angličtině)",
|
||||
"publish_dialog_chip_click_label": "Klikněte na URL",
|
||||
"publish_dialog_chip_email_label": "Přeposlat na e-mail",
|
||||
"publish_dialog_chip_delay_label": "Zpožděné doručení",
|
||||
"publish_dialog_chip_topic_label": "Změnit téma",
|
||||
"publish_dialog_details_examples_description": "Příklady a podrobný popis všech funkcí odesílání naleznete v <docsLink>dokumentaci</docsLink>.",
|
||||
"publish_dialog_chip_attach_url_label": "Připojit soubor pomocí URL",
|
||||
"publish_dialog_chip_attach_file_label": "Připojit místní soubor",
|
||||
"publish_dialog_button_send": "Odeslat",
|
||||
"publish_dialog_checkbox_publish_another": "Odeslat další",
|
||||
"publish_dialog_attached_file_title": "Přiložený soubor:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Název souboru přílohy",
|
||||
"publish_dialog_drop_file_here": "Přetáhněte soubor sem",
|
||||
"emoji_picker_search_placeholder": "Hledat emodži",
|
||||
"subscribe_dialog_subscribe_title": "Přihlásit odběr tématu",
|
||||
"subscribe_dialog_subscribe_description": "Témata nemusí být chráněna heslem, proto zvolte název, který není snadné uhodnout. Jakmile se přihlásíte k odběru, můžete odesílat oznámení pomocí PUT/POST požadavků.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Název tématu, např. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Použít jiný server",
|
||||
"subscribe_dialog_login_title": "Vyžadováno přihlášení",
|
||||
"subscribe_dialog_login_description": "Toto téma je chráněno heslem. Pro přihlášení k odběru zadejte prosím uživatelské jméno a heslo.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Zrušit",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr",
|
||||
"subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil",
|
||||
"subscribe_dialog_login_password_label": "Heslo",
|
||||
"subscribe_dialog_login_button_back": "Zpět",
|
||||
"subscribe_dialog_login_button_login": "Přihlásit se",
|
||||
"subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován",
|
||||
"subscribe_dialog_error_user_anonymous": "anonymně",
|
||||
"prefs_notifications_title": "Oznámení",
|
||||
"prefs_notifications_sound_description_some": "Oznámení přehrají při doručení zvuk {{sound}}",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Zobrazit oznámení, pokud je priorita {{number}} ({{name}}) nebo vyšší",
|
||||
"prefs_notifications_min_priority_description_max": "Zobrazit oznámení, pokud je priorita 5 (nejvyšší)",
|
||||
"prefs_notifications_min_priority_any": "Jakákoli priorita",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Nízká priorita a vyšší",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Výchozí priorita a vyšší",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Vysoká priorita a vyšší",
|
||||
"prefs_notifications_delete_after_three_hours": "Po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day": "Po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week": "Po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month": "Po jednom měsíci",
|
||||
"prefs_notifications_delete_after_never_description": "Oznámení nejsou nikdy automaticky mazána",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Oznámení se automaticky odstraní po třech hodinách",
|
||||
"prefs_notifications_delete_after_one_day_description": "Oznámení se automaticky odstraní po jednom dni",
|
||||
"prefs_notifications_delete_after_one_week_description": "Oznámení se automaticky odstraní po jednom týdnu",
|
||||
"prefs_notifications_delete_after_one_month_description": "Oznámení se automaticky odstraní po jednom měsíci",
|
||||
"prefs_users_title": "Správa uživatelů",
|
||||
"prefs_users_add_button": "Přidat uživatele",
|
||||
"prefs_users_table_user_header": "Uživatel",
|
||||
"prefs_users_table_base_url_header": "URL služby",
|
||||
"prefs_users_dialog_button_cancel": "Zrušit",
|
||||
"prefs_users_dialog_button_add": "Přidat",
|
||||
"prefs_users_dialog_button_save": "Uložit",
|
||||
"priority_min": "nejnižší",
|
||||
"priority_low": "nízká",
|
||||
"priority_default": "výchozí",
|
||||
"priority_high": "vysoká",
|
||||
"priority_max": "nejvyšší",
|
||||
"error_boundary_title": "Ale ne, ntfy havaroval",
|
||||
"error_boundary_description": "K tomu by samozřejmě nemělo docházet. Velmi se za to omlouváme.<br/>Pokud máte chvilku, nahlaste to prosím <githubLink>na GitHubu</githubLink> nebo nám dejte vědět prostřednictvím <discordLink>Discordu</discordLink> nebo <matrixLink>Matrixu</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Kopírovat výpis zásobníku",
|
||||
"error_boundary_stack_trace": "Výpis zásobníku",
|
||||
"publish_dialog_tags_placeholder": "Seznam značek oddělených čárkou, např. warning, srv1-backup",
|
||||
"notifications_actions_not_supported": "Akce není podporována ve webové aplikaci",
|
||||
"notifications_actions_http_request_title": "Odeslat HTTP {{metoda}} na {{url}}",
|
||||
"notifications_no_subscriptions_title": "Vypadá to, že ještě nemáte žádné odběry.",
|
||||
"prefs_notifications_min_priority_description_any": "Zobrazit všechna oznámení bez ohledu na prioritu",
|
||||
"publish_dialog_priority_low": "Nízká priorita",
|
||||
"publish_dialog_message_label": "Zpráva",
|
||||
"publish_dialog_button_cancel": "Zrušit",
|
||||
"notifications_loading": "Načítání oznámení …",
|
||||
"publish_dialog_progress_uploading_detail": "Nahrávání {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_attachment_limits_file_reached": "překračuje {{fileSizeLimit}} limit souboru",
|
||||
"publish_dialog_attach_placeholder": "Připojit soubor pomocí URL, např. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_button_cancel_sending": "Zrušit odesílání",
|
||||
"publish_dialog_other_features": "Další funkce:",
|
||||
"prefs_notifications_sound_title": "Zvuk oznámení",
|
||||
"prefs_notifications_min_priority_max_only": "Pouze nejvyšší priorita",
|
||||
"prefs_notifications_min_priority_title": "Nejnižší priorita",
|
||||
"prefs_notifications_delete_after_title": "Odstranit oznámení",
|
||||
"prefs_notifications_delete_after_never": "Nikdy",
|
||||
"prefs_notifications_sound_no_sound": "Žádný zvuk",
|
||||
"prefs_notifications_sound_description_none": "Oznámení nepřehrají při doručení žádný zvuk",
|
||||
"prefs_users_description": "Zde můžete přidávat/odebírat uživatele pro chráněná témata. Upozorňujeme, že uživatelské jméno a heslo jsou uloženy v místním úložišti prohlížeče.",
|
||||
"error_boundary_gathering_info": "Získejte více informací …",
|
||||
"prefs_appearance_language_title": "Jazyk",
|
||||
"prefs_appearance_title": "Vzhled",
|
||||
"action_bar_show_menu": "Zobrazit nabídku",
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_toggle_mute": "Ztlumení/zrušení ztlumení oznámení",
|
||||
"action_bar_toggle_action_menu": "Otevřít/zavřít nabídku akcí",
|
||||
"message_bar_show_dialog": "Zobrazit okno pro odesílání oznámení",
|
||||
"message_bar_publish": "Odeslat zprávu",
|
||||
"nav_button_muted": "Oznámení ztlumena",
|
||||
"nav_button_connecting": "připojování",
|
||||
"notifications_list": "Seznam oznámení",
|
||||
"notifications_list_item": "Oznámení",
|
||||
"notifications_mark_read": "Označit jako přečtené",
|
||||
"notifications_delete": "Smazat",
|
||||
"notifications_new_indicator": "Nové oznámení",
|
||||
"notifications_attachment_image": "Obrázek přílohy",
|
||||
"notifications_attachment_file_image": "soubor s obrázkem",
|
||||
"notifications_attachment_file_video": "video soubor",
|
||||
"notifications_attachment_file_audio": "zvukový soubor",
|
||||
"notifications_attachment_file_app": "Soubor s aplikací pro Android",
|
||||
"publish_dialog_emoji_picker_show": "Vybrat emoji",
|
||||
"publish_dialog_topic_reset": "Obnovení tématu",
|
||||
"publish_dialog_click_reset": "Odebrat URL kliknutím",
|
||||
"publish_dialog_email_reset": "Odebrat přeposlání e-mailu",
|
||||
"publish_dialog_attach_reset": "Odebrat URL přílohy",
|
||||
"publish_dialog_attached_file_remove": "Odebrat přiložený soubor",
|
||||
"emoji_picker_search_clear": "Vyčistit vyhledávání",
|
||||
"prefs_users_edit_button": "Upravit uživatele",
|
||||
"prefs_users_delete_button": "Odstranit uživatele",
|
||||
"error_boundary_unsupported_indexeddb_title": "Soukromé prohlížení není podporováno",
|
||||
"error_boundary_unsupported_indexeddb_description": "Webová aplikace ntfy potřebuje ke svému fungování databázi IndexedDB a váš prohlížeč v režimu soukromého prohlížení databázi IndexedDB nepodporuje.<br/><br/>To je sice nepříjemné, ale používat webovou aplikaci ntfy v režimu soukromého prohlížení stejně nemá smysl, protože vše je uloženo v úložišti prohlížeče. Více se o tom můžete dočíst <githubLink>v tomto tématu na GitHubu</githubLink>, nebo se na nás obrátit pomocí služeb <discordLink>Discord</discordLink> nebo <matrixLink>Matrix</matrixLink>.",
|
||||
"notifications_priority_x": "Priorita {{priority}}",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL služby",
|
||||
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
|
||||
"prefs_users_table": "Tabulka uživatelů",
|
||||
"notifications_attachment_file_document": "jiný dokument",
|
||||
"publish_dialog_delay_reset": "Odebrat odložené doručení"
|
||||
}
|
||||
@@ -150,5 +150,42 @@
|
||||
"prefs_notifications_delete_after_three_hours_description": "Benachrichtigungen werden nach drei Stunden automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_day_description": "Benachrichtigungen werden nach einem Tag automatisch gelöscht",
|
||||
"prefs_notifications_delete_after_one_week_description": "Benachrichtigungen werden nach einer Woche automatisch gelöscht",
|
||||
"priority_min": "min"
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Diese Aktion wird in der Web-App nicht unterstützt",
|
||||
"notifications_actions_http_request_title": "Sende HTTP {{method}} an {{url}}",
|
||||
"action_bar_show_menu": "Menü anzeigen",
|
||||
"action_bar_toggle_mute": "Stummschaltung der Benachrichtigungen an/aus",
|
||||
"message_bar_show_dialog": "Dialog zur Veröffentlichung anzeigen",
|
||||
"message_bar_publish": "Benachrichtigung veröffentlichen",
|
||||
"nav_button_connecting": "verbinde",
|
||||
"notifications_list": "Benachrichtigungsliste",
|
||||
"notifications_mark_read": "Als gelesen markieren",
|
||||
"notifications_delete": "Löschen",
|
||||
"notifications_priority_x": "Priorität {{priority}}",
|
||||
"notifications_attachment_file_image": "Bilddatei",
|
||||
"notifications_attachment_image": "Bild des Anhangs",
|
||||
"notifications_attachment_file_video": "Videodatei",
|
||||
"notifications_attachment_file_audio": "Audiodatei",
|
||||
"notifications_attachment_file_app": "Android App-Datei",
|
||||
"notifications_attachment_file_document": "anderes Dokument",
|
||||
"publish_dialog_attached_file_remove": "Angehängte Datei entfernen",
|
||||
"emoji_picker_search_clear": "Suche leeren",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"prefs_notifications_sound_play": "Gewählten Sound abspielen",
|
||||
"prefs_users_table": "Benutzertabelle",
|
||||
"prefs_users_edit_button": "Benutzer bearbeiten",
|
||||
"prefs_users_delete_button": "Benutzer löschen",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private Browser-Tabs werden nicht unterstützt",
|
||||
"publish_dialog_delay_reset": "Verzögerte Zustellung entfernen",
|
||||
"error_boundary_unsupported_indexeddb_description": "Die ntfy Web-App benötigt eine IndexedDB für eine korrekte Funktion, und Dein Browser unterstützt in privaten Tabs keinen IndexedDB.<br/><br/>Das ist zwar ärgerlich, eine Nutzung von ntfy in einem privaten Tab macht aber auch wenig Sinn da alle Daten im Browser gespeichert werden. Weitere Informationen gibt es <githubLink>in diesem GitHub-Issue</githubLink>, oder im Chat bei <discordLink>Discord</discordLink> oder <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_toggle_action_menu": "Aktionsmenü öffnen/schließen",
|
||||
"notifications_new_indicator": "Neue Benachrichtigung",
|
||||
"publish_dialog_email_reset": "Email-Weiterleitung entfernen",
|
||||
"action_bar_logo_alt": "ntfy Logo",
|
||||
"nav_button_muted": "Benachrichtigungen stummgeschaltet",
|
||||
"notifications_list_item": "Benachrichtigung",
|
||||
"publish_dialog_emoji_picker_show": "Emoji wählen",
|
||||
"publish_dialog_topic_reset": "Thema zurücksetzen",
|
||||
"publish_dialog_attach_reset": "angehängte URL entfernen",
|
||||
"publish_dialog_click_reset": "Klick-URL entfernen"
|
||||
}
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
{
|
||||
"action_bar_show_menu": "Show menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
"action_bar_toggle_mute": "Mute/unmute notifications",
|
||||
"action_bar_toggle_action_menu": "Open/close action menu",
|
||||
"message_bar_type_message": "Type a message here",
|
||||
"message_bar_error_publishing": "Error publishing notification",
|
||||
"message_bar_show_dialog": "Show publish dialog",
|
||||
"message_bar_publish": "Publish message",
|
||||
"nav_topics_title": "Subscribed topics",
|
||||
"nav_button_all_notifications": "All notifications",
|
||||
"nav_button_settings": "Settings",
|
||||
"nav_button_documentation": "Documentation",
|
||||
"nav_button_publish_message": "Publish notification",
|
||||
"nav_button_subscribe": "Subscribe to topic",
|
||||
"nav_button_muted": "Notifications muted",
|
||||
"nav_button_connecting": "connecting",
|
||||
"alert_grant_title": "Notifications are disabled",
|
||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
||||
"alert_grant_button": "Grant now",
|
||||
"alert_not_supported_title": "Notifications not supported",
|
||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
||||
"notifications_list": "Notifications list",
|
||||
"notifications_list_item": "Notification",
|
||||
"notifications_mark_read": "Mark as read",
|
||||
"notifications_delete": "Delete",
|
||||
"notifications_copied_to_clipboard": "Copied to clipboard",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_priority_x": "Priority {{priority}}",
|
||||
"notifications_new_indicator": "New notification",
|
||||
"notifications_attachment_image": "Attachment image",
|
||||
"notifications_attachment_copy_url_title": "Copy attachment URL to clipboard",
|
||||
"notifications_attachment_copy_url_button": "Copy URL",
|
||||
"notifications_attachment_open_title": "Go to {{url}}",
|
||||
"notifications_attachment_open_button": "Open attachment",
|
||||
"notifications_attachment_link_expires": "link expires {{date}}",
|
||||
"notifications_attachment_link_expired": "download link expired",
|
||||
"notifications_attachment_file_image": "image file",
|
||||
"notifications_attachment_file_video": "video file",
|
||||
"notifications_attachment_file_audio": "audio file",
|
||||
"notifications_attachment_file_app": "Android app file",
|
||||
"notifications_attachment_file_document": "other document",
|
||||
"notifications_click_copy_url_title": "Copy link URL to clipboard",
|
||||
"notifications_click_copy_url_button": "Copy link",
|
||||
"notifications_click_open_button": "Open link",
|
||||
@@ -47,6 +67,7 @@
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "exceeds {{fileSizeLimit}} file limit and quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_attachment_limits_file_reached": "exceeds {{fileSizeLimit}} file limit",
|
||||
"publish_dialog_attachment_limits_quota_reached": "exceeds quota, {{remainingBytes}} remaining",
|
||||
"publish_dialog_emoji_picker_show": "Pick emoji",
|
||||
"publish_dialog_priority_min": "Min. priority",
|
||||
"publish_dialog_priority_low": "Low priority",
|
||||
"publish_dialog_priority_default": "Default priority",
|
||||
@@ -56,6 +77,7 @@
|
||||
"publish_dialog_base_url_placeholder": "Service URL, e.g. https://example.com",
|
||||
"publish_dialog_topic_label": "Topic name",
|
||||
"publish_dialog_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"publish_dialog_topic_reset": "Reset topic",
|
||||
"publish_dialog_title_label": "Title",
|
||||
"publish_dialog_title_placeholder": "Notification title, e.g. Disk space alert",
|
||||
"publish_dialog_message_label": "Message",
|
||||
@@ -65,14 +87,18 @@
|
||||
"publish_dialog_priority_label": "Priority",
|
||||
"publish_dialog_click_label": "Click URL",
|
||||
"publish_dialog_click_placeholder": "URL that is opened when notification is clicked",
|
||||
"publish_dialog_click_reset": "Remove click URL",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com",
|
||||
"publish_dialog_email_reset": "Remove email forward",
|
||||
"publish_dialog_attach_label": "Attachment URL",
|
||||
"publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "Remove attachment URL",
|
||||
"publish_dialog_filename_label": "Filename",
|
||||
"publish_dialog_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_delay_label": "Delay",
|
||||
"publish_dialog_delay_placeholder": "Delay delivery, e.g. {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"publish_dialog_delay_reset": "Remove delayed delivery",
|
||||
"publish_dialog_other_features": "Other features:",
|
||||
"publish_dialog_chip_click_label": "Click URL",
|
||||
"publish_dialog_chip_email_label": "Forward to email",
|
||||
@@ -87,12 +113,15 @@
|
||||
"publish_dialog_checkbox_publish_another": "Publish another",
|
||||
"publish_dialog_attached_file_title": "Attached file:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Attachment filename",
|
||||
"publish_dialog_attached_file_remove": "Remove attached file",
|
||||
"publish_dialog_drop_file_here": "Drop file here",
|
||||
"emoji_picker_search_placeholder": "Search emoji",
|
||||
"emoji_picker_search_clear": "Clear search",
|
||||
"subscribe_dialog_subscribe_title": "Subscribe to topic",
|
||||
"subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Use another server",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Service URL",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancel",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Subscribe",
|
||||
"subscribe_dialog_login_title": "Login required",
|
||||
@@ -108,6 +137,7 @@
|
||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||
"prefs_notifications_sound_description_some": "Notifications play the {{sound}} sound when they arrive",
|
||||
"prefs_notifications_sound_no_sound": "No sound",
|
||||
"prefs_notifications_sound_play": "Play selected sound",
|
||||
"prefs_notifications_min_priority_title": "Minimum priority",
|
||||
"prefs_notifications_min_priority_description_any": "Showing all notifications, regardless of priority",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Show notifications if priority is {{number}} ({{name}}) or above",
|
||||
@@ -130,7 +160,10 @@
|
||||
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
|
||||
"prefs_users_title": "Manage users",
|
||||
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
|
||||
"prefs_users_table": "Users table",
|
||||
"prefs_users_add_button": "Add user",
|
||||
"prefs_users_edit_button": "Edit user",
|
||||
"prefs_users_delete_button": "Delete user",
|
||||
"prefs_users_table_user_header": "User",
|
||||
"prefs_users_table_base_url_header": "Service URL",
|
||||
"prefs_users_dialog_title_add": "Add user",
|
||||
@@ -152,5 +185,7 @@
|
||||
"error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copy stack trace",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Gather more info …"
|
||||
"error_boundary_gathering_info": "Gather more info …",
|
||||
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
|
||||
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
|
||||
@@ -150,5 +150,42 @@
|
||||
"priority_default": "predeterminada",
|
||||
"prefs_notifications_delete_after_one_day_description": "Las notificaciones se eliminan automáticamente después de un día",
|
||||
"prefs_notifications_delete_after_one_week_description": "Las notificaciones se eliminan automáticamente después de una semana",
|
||||
"priority_low": "baja"
|
||||
"priority_low": "baja",
|
||||
"notifications_actions_not_supported": "Acción no soportada en la aplicación web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}",
|
||||
"error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada. <br/> <br/> Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto <githubLink>en este issue de GitHub</githubLink>, o hablar con nosotros en <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_show_menu": "Mostrar menú",
|
||||
"action_bar_logo_alt": "logo de ntfy",
|
||||
"action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción",
|
||||
"message_bar_show_dialog": "Mostrar diálogo de publicación",
|
||||
"message_bar_publish": "Publicar mensaje",
|
||||
"nav_button_muted": "Notificaciones silenciadas",
|
||||
"nav_button_connecting": "conectando",
|
||||
"notifications_list": "Lista de notificaciones",
|
||||
"notifications_list_item": "Notificación",
|
||||
"notifications_mark_read": "Marcar como leído",
|
||||
"notifications_delete": "Eliminar",
|
||||
"notifications_priority_x": "Prioridad {{priority}}",
|
||||
"notifications_new_indicator": "Nueva notificación",
|
||||
"notifications_attachment_image": "Imagen adjunta",
|
||||
"notifications_attachment_file_image": "archivo de imagen",
|
||||
"notifications_attachment_file_video": "archivo de video",
|
||||
"notifications_attachment_file_audio": "archivo de audio",
|
||||
"notifications_attachment_file_app": "Archivo de aplicación de Android",
|
||||
"notifications_attachment_file_document": "otro documento",
|
||||
"action_bar_toggle_mute": "Silenciar/reactivar notificaciones",
|
||||
"publish_dialog_emoji_picker_show": "Elige un emoji",
|
||||
"publish_dialog_topic_reset": "Restablecer tópico",
|
||||
"publish_dialog_click_reset": "Eliminar URL de clic",
|
||||
"publish_dialog_email_reset": "Eliminar el reenvío de correo electrónico",
|
||||
"publish_dialog_attach_reset": "Eliminar la URL del archivo adjunto",
|
||||
"publish_dialog_delay_reset": "Eliminar entrega retrasada",
|
||||
"publish_dialog_attached_file_remove": "Eliminar el archivo adjunto",
|
||||
"emoji_picker_search_clear": "Limpiar búsqueda",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL del servicio",
|
||||
"prefs_notifications_sound_play": "Reproducir el sonido seleccionado",
|
||||
"prefs_users_table": "Tabla de usuarios",
|
||||
"prefs_users_edit_button": "Editar usuario",
|
||||
"prefs_users_delete_button": "Eliminar usuario",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
"notifications_example": "Exemple",
|
||||
"notifications_loading": "Chargement des notifications…",
|
||||
"publish_dialog_progress_uploading": "Téléversement…",
|
||||
"publish_dialog_priority_min": "Priorité min.",
|
||||
"publish_dialog_priority_min": "Priorité minimum",
|
||||
"publish_dialog_priority_low": "Basse priorité",
|
||||
"publish_dialog_priority_default": "Priorité par défaut",
|
||||
"publish_dialog_base_url_label": "URL du serveur",
|
||||
"publish_dialog_base_url_placeholder": "URL du serveur, par ex. https://exemple.com",
|
||||
"publish_dialog_base_url_label": "URL du service",
|
||||
"publish_dialog_base_url_placeholder": "URL du service, par ex. https://exemple.com",
|
||||
"publish_dialog_title_label": "Titre",
|
||||
"publish_dialog_message_label": "Message",
|
||||
"publish_dialog_topic_label": "Nom du sujet",
|
||||
@@ -46,11 +46,111 @@
|
||||
"publish_dialog_message_published": "Notification publiée",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "dépasse la limite et le quota du fichier {{fileSizeLimit}}, {{remainingBytes}} restant",
|
||||
"publish_dialog_priority_high": "Haute priorité",
|
||||
"publish_dialog_priority_max": "Priorité max.",
|
||||
"publish_dialog_priority_max": "Priorité maximum",
|
||||
"publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
|
||||
"nav_button_subscribe": "S'abonner au sujet",
|
||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « Ajouter un abonnement » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
|
||||
"alert_grant_title": "Les notifications sont désactivées",
|
||||
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
|
||||
"alert_grant_button": "Accorder maintenant"
|
||||
"alert_grant_button": "Accorder maintenant",
|
||||
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
|
||||
"publish_dialog_title_topic": "Publier vers {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publier la notification",
|
||||
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
|
||||
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
|
||||
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
|
||||
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
|
||||
"publish_dialog_other_features": "Autres fonctionnalités :",
|
||||
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
|
||||
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
|
||||
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
|
||||
"publish_dialog_priority_label": "Priorité",
|
||||
"publish_dialog_click_label": "Cliquer sur l'URL",
|
||||
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
|
||||
"publish_dialog_attach_label": "URL de la pièce jointe",
|
||||
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Nom du fichier",
|
||||
"notifications_none_for_topic_description": "Pour envoyer des notifications à ce sujet, faites simplement une requête PUT ou POST à l'URL du sujet.",
|
||||
"notifications_none_for_any_description": "Pour envoyer des notifications à un sujet, faites simplement une requête PUT ou POST à l'URL du sujet. Voici un exemple utilisant un de vos sujets.",
|
||||
"publish_dialog_filename_placeholder": "Nom du fichier joint",
|
||||
"publish_dialog_delay_label": "Délai",
|
||||
"publish_dialog_chip_click_label": "Cliquez sur l'URL",
|
||||
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||
"prefs_users_dialog_button_cancel": "Annuler",
|
||||
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Utiliser un autre serveur",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annuler",
|
||||
"prefs_notifications_sound_description_none": "Les notifications ne font aucun son quand elles arrivent",
|
||||
"prefs_notifications_sound_description_some": "Les notifications jouent le son {{sound}} quand elles arrivent",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Montrer les notifications si leur priorité est {{number}} ({{name}}) ou plus",
|
||||
"publish_dialog_button_cancel": "Annuler",
|
||||
"publish_dialog_button_send": "Envoyer",
|
||||
"publish_dialog_drop_file_here": "Déposez un fichier ici",
|
||||
"emoji_picker_search_placeholder": "Chercher un émoji",
|
||||
"subscribe_dialog_subscribe_description": "Le sujet n'est peut-être pas protégé par un mot de passe, choisissez un nom de sujet difficile à deviner. Une fois abonné, vous pouvez PUT/POST des notifications.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nom de sujet, par ex. alertes_de_phil",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "S'abonner",
|
||||
"subscribe_dialog_login_description": "Ce sujet est protégé par un mot de passe. Veuillez entrer le nom d'utilisateur et le mot de passe pour vous abonner.",
|
||||
"subscribe_dialog_login_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"subscribe_dialog_login_button_login": "Connexion",
|
||||
"prefs_notifications_sound_title": "Son de notification",
|
||||
"prefs_notifications_delete_after_never": "Jamais",
|
||||
"prefs_users_table_base_url_header": "URL de service",
|
||||
"subscribe_dialog_login_password_label": "Mot de passe",
|
||||
"prefs_notifications_title": "Notifications",
|
||||
"prefs_notifications_delete_after_title": "Supprimer les notifications",
|
||||
"prefs_users_add_button": "Ajouter un utilisateur",
|
||||
"subscribe_dialog_login_button_back": "Retour",
|
||||
"subscribe_dialog_error_user_anonymous": "anonyme",
|
||||
"prefs_notifications_sound_no_sound": "Aucun son",
|
||||
"prefs_notifications_min_priority_title": "Priorité minimum",
|
||||
"prefs_notifications_min_priority_description_any": "Montrer toutes les notifications, quelque soit leur priorité",
|
||||
"prefs_notifications_min_priority_description_max": "Montrer les notifications si la priorité est 5 (max)",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Priorité par défaut et au-dessus",
|
||||
"prefs_notifications_min_priority_max_only": "Seulement la priorité maximale",
|
||||
"prefs_notifications_delete_after_three_hours": "Après trois heures",
|
||||
"prefs_notifications_delete_after_one_day": "Après un jour",
|
||||
"subscribe_dialog_error_user_not_authorized": "L'utilisateur {{username}} n'est pas autorisé",
|
||||
"prefs_notifications_min_priority_any": "N'importe quelle priorité",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Priorité haute et au-dessus",
|
||||
"prefs_users_dialog_base_url_label": "URL du service, par ex. https://ntfy.sh",
|
||||
"prefs_notifications_delete_after_one_week_description": "Les notifications sont supprimées automatiquement après une semaine",
|
||||
"prefs_users_dialog_username_label": "Nom d'utilisateur, par ex. phil",
|
||||
"prefs_users_dialog_password_label": "Mot de passe",
|
||||
"prefs_notifications_delete_after_one_month_description": "Les notifications sont supprimées automatiquement après un mois",
|
||||
"prefs_users_title": "Gérer les utilisateurs",
|
||||
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
|
||||
"prefs_users_table_user_header": "Utilisateur",
|
||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||
"prefs_users_dialog_button_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||
"prefs_notifications_delete_after_one_week": "Après une semaine",
|
||||
"prefs_notifications_delete_after_one_month": "Après un mois",
|
||||
"prefs_notifications_delete_after_never_description": "Les notifications ne sont jamais supprimées automatiquement",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Les notifications sont supprimées automatiquement après trois heures",
|
||||
"prefs_notifications_delete_after_one_day_description": "Les notifications sont supprimées automatiquement après un jour",
|
||||
"prefs_appearance_title": "Apparence",
|
||||
"prefs_appearance_language_title": "Langue",
|
||||
"priority_min": "min",
|
||||
"priority_low": "basse",
|
||||
"priority_default": "défault",
|
||||
"priority_high": "haute",
|
||||
"priority_max": "max",
|
||||
"error_boundary_title": "Oh non, ntfy a planté",
|
||||
"publish_dialog_chip_attach_url_label": "Joindre un fichier par URL",
|
||||
"publish_dialog_chip_attach_file_label": "Joindre un fichier local",
|
||||
"publish_dialog_chip_delay_label": "Délayer l'envoi",
|
||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
||||
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||
"prefs_users_dialog_button_save": "Enregistrer"
|
||||
}
|
||||
|
||||
156
web/public/static/langs/hu.json
Normal file
156
web/public/static/langs/hu.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"action_bar_send_test_notification": "Teszt értesítés küldése",
|
||||
"action_bar_clear_notifications": "Összes értesítés törlése",
|
||||
"alert_not_supported_description": "A böngésző nem támogatja az értesítések fogadását.",
|
||||
"action_bar_settings": "Beállítások",
|
||||
"action_bar_unsubscribe": "Leiratkozás",
|
||||
"message_bar_type_message": "Írd ide az üzenetet",
|
||||
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
|
||||
"nav_button_all_notifications": "Összes értesítés",
|
||||
"nav_topics_title": "Feliratkozott témák",
|
||||
"alert_grant_title": "Az értesítések le vannak tiltva",
|
||||
"alert_grant_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
|
||||
"nav_button_settings": "Beállítások",
|
||||
"nav_button_documentation": "Dokumentáció",
|
||||
"nav_button_publish_message": "Értesítés küldése",
|
||||
"alert_grant_button": "Engedélyezés",
|
||||
"alert_not_supported_title": "Nem támogatott funkció",
|
||||
"notifications_copied_to_clipboard": "Másolva a vágólapra",
|
||||
"notifications_tags": "Címkék",
|
||||
"notifications_attachment_copy_url_title": "Másolja vágólapra a csatolmány URL-ét",
|
||||
"notifications_attachment_copy_url_button": "URL másolása",
|
||||
"notifications_attachment_open_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_attachment_open_button": "Csatolmány megnyitása",
|
||||
"notifications_attachment_link_expired": "A letöltési hivatkozás lejárt",
|
||||
"notifications_attachment_link_expires": "A hivatkozás {{date}}-kor jár le",
|
||||
"nav_button_subscribe": "Feliratkozás témára",
|
||||
"notifications_click_copy_url_title": "Másolja vágólapra a hivatkozás URL-ét",
|
||||
"notifications_actions_open_url_title": "Menjen a(z) {{url}} címre",
|
||||
"notifications_actions_not_supported": "A művelet nem támogatott a webes alkalmazásban",
|
||||
"notifications_actions_http_request_title": "Küldjön HTTP {{method}} kérést a(z) {{url}} címre",
|
||||
"notifications_none_for_topic_title": "Még nem érkezett értesítés erre a témára.",
|
||||
"notifications_none_for_any_title": "Még nem érkezett egy értesítés sem.",
|
||||
"notifications_none_for_any_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére. Itt egy példa az egyik témádhoz.",
|
||||
"notifications_no_subscriptions_title": "Úgy tűnik, még nem iratkoztál fel egy témára sem.",
|
||||
"publish_dialog_message_published": "Értesítés elküldve",
|
||||
"notifications_example": "Példa",
|
||||
"notifications_no_subscriptions_description": "Kattints a \"{{linktext}}\" linkre egy téma létrehozásához, vagy rá feliratkozáshoz. Ezután PUT, vagy POST kéréssel fogsz tudni értesítéseket küldeni rá, amik utána meg fognak itt jelenni.",
|
||||
"publish_dialog_priority_low": "Alacsony prioritás",
|
||||
"publish_dialog_priority_default": "Közepes prioritás",
|
||||
"publish_dialog_priority_high": "Magas prioritás",
|
||||
"notifications_more_details": "További információkért keresd fel a <websiteLink>weboldalunkat</websiteLink> vagy olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"publish_dialog_title_no_topic": "Értesítés küldése",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}}) és a kvótát is ({{remainingBytes}} maradt)",
|
||||
"publish_dialog_attachment_limits_quota_reached": "túllépi a kvótát, {{remainingBytes}} maradt",
|
||||
"publish_dialog_priority_min": "Legkisebb prioritás",
|
||||
"publish_dialog_base_url_label": "A szolgáltatás URL-e",
|
||||
"publish_dialog_base_url_placeholder": "A szolgáltatás URL-e, pl: https://example.com",
|
||||
"publish_dialog_topic_label": "Téma neve",
|
||||
"publish_dialog_priority_max": "Legmagasabb prioritás",
|
||||
"publish_dialog_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"publish_dialog_title_label": "Cím",
|
||||
"publish_dialog_title_placeholder": "Értesítés címe, pl: Fogy a szabad hely",
|
||||
"publish_dialog_message_label": "Üzenet",
|
||||
"publish_dialog_message_placeholder": "Írj ide egy üzenetet",
|
||||
"publish_dialog_tags_label": "Címkék",
|
||||
"publish_dialog_tags_placeholder": "Címkék vesszővel elválasztva, pl: fontos,srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioritás",
|
||||
"publish_dialog_click_label": "URL",
|
||||
"publish_dialog_click_placeholder": "Webcím, ami megnyílik, ha az értesítésre kattintanak",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email cím, amire továbbítjuk az értesítést, pl: jozsi@example.com",
|
||||
"publish_dialog_attach_label": "Csatolmány URL-e",
|
||||
"publish_dialog_filename_label": "Fájlnév",
|
||||
"publish_dialog_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_delay_label": "Késleltetés",
|
||||
"publish_dialog_delay_placeholder": "Késleltetett küldés, pl: {{unixTimestamp}}, {{relativeTime}}, vagy \"{{naturalLanguage}}\" (Csak angolul)",
|
||||
"publish_dialog_other_features": "Egyéb lehetőségek:",
|
||||
"publish_dialog_chip_click_label": "Kattintási URL",
|
||||
"publish_dialog_chip_attach_file_label": "Helyi fájl csatolása",
|
||||
"publish_dialog_chip_delay_label": "Késleltetett kézbesítés",
|
||||
"publish_dialog_chip_topic_label": "Téma megváltoztatása",
|
||||
"publish_dialog_button_cancel_sending": "Küldés megállítása",
|
||||
"publish_dialog_button_cancel": "Mégsem",
|
||||
"publish_dialog_checkbox_publish_another": "Küldök még egyet",
|
||||
"publish_dialog_attached_file_title": "Csatolt fájl:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Csatolmány fájlneve",
|
||||
"publish_dialog_drop_file_here": "Ejtsd ide a fájlt",
|
||||
"emoji_picker_search_placeholder": "Emoji keresése",
|
||||
"publish_dialog_details_examples_description": "Példákért és az összes küldési képesség részletes leírásához olvasd el a <docsLink>dokumentációt</docsLink>.",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Használjon másik szervert",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Feliratkozás",
|
||||
"subscribe_dialog_login_title": "Be kell jelentkezni",
|
||||
"subscribe_dialog_subscribe_description": "A témák nem mindig vannak jelszóval védve, ezért olyan nevet válassz, ami nehezen található ki. Miután feliratkoztál, küldhetsz értesítéseket.",
|
||||
"subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.",
|
||||
"subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi",
|
||||
"subscribe_dialog_login_password_label": "Jelszó",
|
||||
"subscribe_dialog_login_button_back": "Vissza",
|
||||
"subscribe_dialog_login_button_login": "Belépés",
|
||||
"subscribe_dialog_error_user_anonymous": "névtelen",
|
||||
"subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése",
|
||||
"prefs_notifications_min_priority_description_any": "Minden értesítést mutat, prioritástól függetlenül",
|
||||
"prefs_notifications_min_priority_description_max": "Csak az 5-ös (legmagasabb) prioritású értesítések jelennek meg",
|
||||
"prefs_notifications_min_priority_any": "Bármilyen prioritás",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Alacsony prioritás, vagy magasabb",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Magas, vagy legmagasabb prioritás",
|
||||
"prefs_notifications_min_priority_max_only": "Csak a legmagasabb prioritás",
|
||||
"prefs_notifications_sound_title": "Értesítés hangja",
|
||||
"prefs_notifications_sound_description_none": "Az értesítések nem fognak hangot adni, amikor megérkeznek",
|
||||
"prefs_notifications_sound_no_sound": "Hang nélkül",
|
||||
"prefs_notifications_delete_after_one_week": "1 hét után",
|
||||
"prefs_notifications_delete_after_one_month": "1 hónap után",
|
||||
"prefs_notifications_delete_after_never_description": "Az értesítések soha nem lesznek automatikusan törölve",
|
||||
"prefs_notifications_delete_after_three_hours_description": "A 3 óránál régebbi értesítések automatikus törlése",
|
||||
"prefs_notifications_delete_after_one_day_description": "Az egy napnál régebbi értesítések automatikus törlése",
|
||||
"prefs_users_description": "Itt tudsz hozzáadni/eltávolítani felhasználókat a védett témákról. Fontos, hogy a felhasználónevet és a jelszót a böngésző helyi tárolójába fogjuk menteni.",
|
||||
"prefs_users_table_user_header": "Felhasználó",
|
||||
"prefs_users_table_base_url_header": "Szerver címe",
|
||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||
"prefs_users_dialog_password_label": "Jelszó",
|
||||
"prefs_users_dialog_button_add": "Hozzáadás",
|
||||
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||
"notifications_loading": "Értesítések betöltése …",
|
||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||
"notifications_click_copy_url_button": "Hivatkozás másolása",
|
||||
"notifications_click_open_button": "Hivatkozás megnyitása",
|
||||
"publish_dialog_progress_uploading_detail": "Feltöltés folyamatban: {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"notifications_none_for_topic_description": "Értesítés beküldéséhez csak küldj egy PUT, vagy POST kérést a téma URL-ére.",
|
||||
"prefs_notifications_delete_after_one_day": "1 nap után",
|
||||
"publish_dialog_attach_placeholder": "Csatolandó fájl címe, pl: https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Továbbítás email-ben",
|
||||
"publish_dialog_chip_attach_url_label": "Fájl csatolása URL-lel",
|
||||
"publish_dialog_button_send": "Küldés",
|
||||
"subscribe_dialog_subscribe_title": "Feliratkozás témára",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Mégsem",
|
||||
"prefs_notifications_min_priority_title": "Legkisebb megjelenítendő prioritás",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Csak akkor jelenik meg egy értesítés, ha a prioritása {{number}} ({{name}}), vagy fontosabb",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Közepes prioritás, vagy magasabb",
|
||||
"prefs_notifications_delete_after_one_week_description": "Az egy hétnél régebbi értesítések automatikus törlése",
|
||||
"prefs_users_add_button": "Felhasználó hozzáadása",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Téma neve, pl: jozsi_riasztasai",
|
||||
"prefs_notifications_title": "Értesítések",
|
||||
"error_boundary_button_copy_stack_trace": "Verem nyomkövetés másolása",
|
||||
"prefs_notifications_delete_after_title": "Régi értesítések törlése",
|
||||
"prefs_notifications_delete_after_three_hours": "3 óra után",
|
||||
"error_boundary_title": "Jaj ne, az ntfy összeomlott",
|
||||
"prefs_notifications_delete_after_never": "Soha",
|
||||
"prefs_notifications_delete_after_one_month_description": "Az egy hónapnál régebbi értesítések automatikus törlése",
|
||||
"prefs_appearance_title": "Megjelenés",
|
||||
"priority_default": "közepes",
|
||||
"priority_high": "magas",
|
||||
"priority_max": "legmagasabb",
|
||||
"priority_min": "legkisebb",
|
||||
"error_boundary_gathering_info": "Több információ…",
|
||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
||||
"prefs_users_title": "Felhasználók kezelése",
|
||||
"prefs_users_dialog_button_cancel": "Mégsem",
|
||||
"prefs_users_dialog_button_save": "Mentés",
|
||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||
"prefs_appearance_language_title": "Nyelv",
|
||||
"priority_low": "alacsony",
|
||||
"error_boundary_stack_trace": "Verem nyomkövetés",
|
||||
"publish_dialog_title_topic": "A {{topic}} téma értesítése",
|
||||
"prefs_notifications_sound_description_some": "Az értesítéseket a(z) {{sound}} hang fogja jelezni",
|
||||
"error_boundary_description": "Ennek nem szabadott volna megtörténnie. Nagyon sajnáljuk.<br/>Ha van egy perced, <githubLink>jelentsd be GitHubon</githubLink>, vagy tudasd velünk <discordLink>Discordon</discordLink>, vagy <matrixLink>Matrixon</matrixLink>."
|
||||
}
|
||||
@@ -150,5 +150,42 @@
|
||||
"prefs_notifications_delete_after_never_description": "Notifikasi tidak pernah dihapus secara otomatis",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notifikasi dihapus secara otomatis setelah satu hari",
|
||||
"priority_default": "bawaan",
|
||||
"priority_min": "min"
|
||||
"priority_min": "min",
|
||||
"notifications_actions_not_supported": "Tindakan tidak didukung di aplikasi web",
|
||||
"notifications_actions_http_request_title": "Kirim {{method}} HTTP ke {{url}}",
|
||||
"action_bar_show_menu": "Tampilkan menu",
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_toggle_mute": "Bisu/suarakan notifikasi",
|
||||
"action_bar_toggle_action_menu": "Buka/tutup menu tindakan",
|
||||
"message_bar_show_dialog": "Tampilkan dialog publikasi",
|
||||
"message_bar_publish": "Publikasikan pesan",
|
||||
"nav_button_muted": "Notifikasi dibisukan",
|
||||
"nav_button_connecting": "menghubungkan",
|
||||
"notifications_list": "Daftar notifikasi",
|
||||
"notifications_list_item": "Notifikasi",
|
||||
"notifications_mark_read": "Tandai sebagai dibaca",
|
||||
"notifications_delete": "Hapus",
|
||||
"notifications_priority_x": "Prioritas {{priority}}",
|
||||
"notifications_new_indicator": "Notifikasi baru",
|
||||
"notifications_attachment_image": "Lampiran gambar",
|
||||
"notifications_attachment_file_image": "file gambar",
|
||||
"notifications_attachment_file_video": "file",
|
||||
"notifications_attachment_file_audio": "file audio",
|
||||
"notifications_attachment_file_app": "file aplikasi Android",
|
||||
"notifications_attachment_file_document": "dokumen lainnya",
|
||||
"publish_dialog_emoji_picker_show": "Pilih emoji",
|
||||
"publish_dialog_topic_reset": "Atur ulang topik",
|
||||
"publish_dialog_click_reset": "Hapus URL klik",
|
||||
"publish_dialog_email_reset": "Hapus terusan email",
|
||||
"publish_dialog_attach_reset": "Hapus URL lampiran",
|
||||
"publish_dialog_delay_reset": "Hapus pengiriman telat",
|
||||
"publish_dialog_attached_file_remove": "Hapus file yang dilampirkan",
|
||||
"emoji_picker_search_clear": "Hapus pencarian",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL layanan",
|
||||
"prefs_notifications_sound_play": "Mainkan suara yang dipilih",
|
||||
"prefs_users_table": "Tabel pengguna",
|
||||
"prefs_users_edit_button": "Edit pengguna",
|
||||
"prefs_users_delete_button": "Hapus pengguna",
|
||||
"error_boundary_unsupported_indexeddb_description": "Aplikasi web ntfy membutuhkan IndexedDB untuk berfungsi, dan peramban Anda tidak mendukung IndexedDB dalam mode penjelajahan pribadi.<br/><br/>Meskipun ini disayangkan, penggunaan aplikasi web ntfy juga tidak masuk akal di mode penjelajahan pribadi, karena semuanya disimpan di penyimpanan peramban. Anda dapat membaca lebih lanjut tentangnya <githubLink>di masalah GitHub ini</githubLink>, atau berbicara dengan kami di <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_unsupported_indexeddb_title": "Penjelajahan privat tidak didukung"
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"publish_dialog_message_label": "メッセージ",
|
||||
"publish_dialog_email_label": "メール",
|
||||
"notifications_none_for_any_title": "まだ通知を受信していません。",
|
||||
"publish_dialog_priority_max": "優先度最高",
|
||||
"publish_dialog_priority_max": "優先度 最高",
|
||||
"publish_dialog_button_cancel_sending": "送信をキャンセル",
|
||||
"publish_dialog_attach_label": "添付URL",
|
||||
"notifications_none_for_any_description": "トピックに通知を送信するには、トピックURLにPUTまたはPOSTしてください。トピックのひとつを利用した例を示します。",
|
||||
@@ -60,14 +60,14 @@
|
||||
"publish_dialog_email_placeholder": "通知を転送するアドレス, 例) phil@example.com",
|
||||
"notifications_more_details": "詳しい情報は、<websiteLink>ウェブサイト</websiteLink> または <docsLink>ドキュメント</docsLink> を参照してください。",
|
||||
"publish_dialog_attachment_limits_file_reached": "ファイルサイズ制限 {{fileSizeLimit}} を超えました",
|
||||
"publish_dialog_priority_min": "優先度最低",
|
||||
"publish_dialog_priority_low": "優先度低",
|
||||
"publish_dialog_priority_default": "優先度通常",
|
||||
"publish_dialog_priority_min": "優先度 最低",
|
||||
"publish_dialog_priority_low": "優先度 低",
|
||||
"publish_dialog_priority_default": "優先度 通常",
|
||||
"publish_dialog_base_url_label": "サービスURL",
|
||||
"publish_dialog_other_features": "他の機能:",
|
||||
"notifications_loading": "通知を読み込み中…",
|
||||
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "優先度高",
|
||||
"publish_dialog_priority_high": "優先度 高",
|
||||
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
|
||||
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
|
||||
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
|
||||
@@ -129,7 +129,7 @@
|
||||
"prefs_users_table_base_url_header": "サービスURL",
|
||||
"prefs_users_dialog_username_label": "ユーザー名, 例) phil",
|
||||
"prefs_users_dialog_password_label": "パスワード",
|
||||
"error_boundary_title": "ああ、ntfyがクラッシュしました",
|
||||
"error_boundary_title": "おっと、ntfyがクラッシュしました",
|
||||
"error_boundary_button_copy_stack_trace": "スタックトレースをコピー",
|
||||
"error_boundary_stack_trace": "スタックトレース",
|
||||
"error_boundary_gathering_info": "更に情報を集める…",
|
||||
@@ -150,5 +150,42 @@
|
||||
"priority_default": "通常",
|
||||
"prefs_notifications_delete_after_three_hours_description": "通知は3時間後に自動的に削除されます",
|
||||
"priority_low": "低",
|
||||
"priority_min": "最低"
|
||||
"priority_min": "最低",
|
||||
"notifications_actions_not_supported": "このアクションはWebアプリではサポートされていません",
|
||||
"notifications_actions_http_request_title": "{{url}}にHTTP {{method}}を送信",
|
||||
"prefs_users_edit_button": "ユーザーを編集",
|
||||
"publish_dialog_attached_file_remove": "添付ファイルを削除",
|
||||
"error_boundary_unsupported_indexeddb_description": "nfty webアプリは動作にIndexedDBを使用しますが、あなたのブラウザはプライベートブラウジングモード時にIndexedDBをサポートしていません。<br/><br/>これは残念なことですが、ntfy webアプリは全ての情報をブラウザストレージに保存して動作するため、プライベートブラウジングモードで利用するのはあまり意味がないかも知れません。詳細については <githubLink>GitHub issue</githubLink>を参照するか、<discordLink>Discord</discordLink>や<matrixLink>Matrix</matrixLink>の議論に参加してください。",
|
||||
"action_bar_show_menu": "メニューを表示",
|
||||
"action_bar_logo_alt": "ntfyロゴ",
|
||||
"action_bar_toggle_mute": "通知をミュート/解除",
|
||||
"action_bar_toggle_action_menu": "動作メニューを開く/閉じる",
|
||||
"message_bar_show_dialog": "送信ダイアログを表示",
|
||||
"message_bar_publish": "メッセージを送信",
|
||||
"nav_button_muted": "ミュートされた通知",
|
||||
"nav_button_connecting": "接続中",
|
||||
"notifications_list": "通知一覧",
|
||||
"notifications_new_indicator": "新しい通知",
|
||||
"notifications_list_item": "通知",
|
||||
"notifications_mark_read": "既読にする",
|
||||
"notifications_delete": "削除",
|
||||
"notifications_priority_x": "優先度 {{priority}}",
|
||||
"notifications_attachment_image": "添付画像",
|
||||
"notifications_attachment_file_image": "画像ファイル",
|
||||
"notifications_attachment_file_video": "動画ファイル",
|
||||
"notifications_attachment_file_audio": "音声ファイル",
|
||||
"notifications_attachment_file_app": "Androidアプリファイル",
|
||||
"notifications_attachment_file_document": "その他文書",
|
||||
"publish_dialog_emoji_picker_show": "絵文字",
|
||||
"publish_dialog_topic_reset": "トピックをリセット",
|
||||
"publish_dialog_click_reset": "クリックURLを削除",
|
||||
"publish_dialog_email_reset": "メール転送を削除",
|
||||
"publish_dialog_attach_reset": "添付URLを削除",
|
||||
"publish_dialog_delay_reset": "配信遅延を削除",
|
||||
"emoji_picker_search_clear": "検索をクリア",
|
||||
"subscribe_dialog_subscribe_base_url_label": "サーバーURL",
|
||||
"prefs_notifications_sound_play": "選択されたサウンドを再生",
|
||||
"prefs_users_table": "ユーザー一覧",
|
||||
"prefs_users_delete_button": "ユーザーを削除",
|
||||
"error_boundary_unsupported_indexeddb_title": "プライベートブラウジングはサポートされていません"
|
||||
}
|
||||
|
||||
7
web/public/static/langs/nl.json
Normal file
7
web/public/static/langs/nl.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"action_bar_settings": "Instellingen",
|
||||
"action_bar_send_test_notification": "Stuur testmelding",
|
||||
"action_bar_clear_notifications": "Alle meldingen wissen",
|
||||
"message_bar_type_message": "Typ hier een bericht",
|
||||
"action_bar_unsubscribe": "Afmelden"
|
||||
}
|
||||
@@ -34,5 +34,158 @@
|
||||
"notifications_attachment_link_expires": "link expira em {{date}}",
|
||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||
"notifications_attachment_link_expired": "link para transferência expirado",
|
||||
"notifications_example": "Exemplo"
|
||||
"notifications_example": "Exemplo",
|
||||
"notifications_more_details": "Para mais informações, confira <websiteLink>site</websiteLink> ou <docsLink>documentação</docsLink>.",
|
||||
"notifications_loading": "Carregando notificações…",
|
||||
"subscribe_dialog_error_user_anonymous": "anônimo",
|
||||
"prefs_notifications_delete_after_three_hours": "Após três horas",
|
||||
"prefs_notifications_delete_after_one_day": "Após um dia",
|
||||
"prefs_notifications_delete_after_one_week": "Após uma semana",
|
||||
"prefs_notifications_delete_after_one_month": "Após um mês",
|
||||
"notifications_actions_not_supported": "Ação não suportada no aplicativo web",
|
||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
|
||||
"notifications_actions_open_url_title": "Ir para {{url}}",
|
||||
"publish_dialog_title_topic": "Publicar em {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publicar notificação",
|
||||
"publish_dialog_progress_uploading": "Enviando …",
|
||||
"publish_dialog_progress_uploading_detail": "Fazendo upload de {{loaded}}/{{total}} ({{percent}}%)…",
|
||||
"publish_dialog_message_published": "Notificação publicada",
|
||||
"publish_dialog_attachment_limits_file_reached": "excede o limite de arquivo {{fileSizeLimit}}",
|
||||
"publish_dialog_priority_min": "Prioridade mínima",
|
||||
"publish_dialog_priority_low": "Baixa prioridade",
|
||||
"publish_dialog_priority_default": "Prioridade padrão",
|
||||
"publish_dialog_base_url_label": "URL de serviço",
|
||||
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo https://example.com",
|
||||
"publish_dialog_topic_label": "Nome do tópico",
|
||||
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts",
|
||||
"publish_dialog_title_label": "Título",
|
||||
"publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco",
|
||||
"publish_dialog_message_label": "Mensagem",
|
||||
"publish_dialog_message_placeholder": "Digite uma mensagem aqui",
|
||||
"publish_dialog_tags_label": "Etiquetas",
|
||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup",
|
||||
"publish_dialog_priority_label": "Prioridade",
|
||||
"publish_dialog_click_label": "Clique em URL",
|
||||
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Email para encaminhar a notificação, por exemplo phil@example.com",
|
||||
"publish_dialog_filename_label": "Nome do arquivo",
|
||||
"publish_dialog_filename_placeholder": "Nome do arquivo anexado",
|
||||
"publish_dialog_delay_label": "Atraso",
|
||||
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo {{{unixTimestamp}}, {{relativeTime}}, ou \"{{naturalLanguage}}\" (apenas em inglês)",
|
||||
"publish_dialog_other_features": "Outros recursos:",
|
||||
"publish_dialog_chip_click_label": "Clique em URL",
|
||||
"publish_dialog_chip_attach_file_label": "Anexar arquivo local",
|
||||
"publish_dialog_chip_delay_label": "Atraso na entrega",
|
||||
"publish_dialog_chip_topic_label": "Alterar tópico",
|
||||
"publish_dialog_button_cancel_sending": "Cancelar o envio",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nome do arquivo anexado",
|
||||
"publish_dialog_drop_file_here": "Solte o arquivo aqui",
|
||||
"emoji_picker_search_placeholder": "Pesquisar emoji",
|
||||
"subscribe_dialog_subscribe_title": "Inscrever no tópico",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
|
||||
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por senha, então escolha um nome que não seja fácil de adivinhar. Uma vez inscrito, você pode PUT/POST notificações.",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo phil_alerts",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Inscrever",
|
||||
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
|
||||
"prefs_notifications_min_priority_any": "Qualquer prioridade",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
||||
"subscribe_dialog_login_password_label": "Senha",
|
||||
"subscribe_dialog_login_button_back": "Voltar",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima",
|
||||
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
||||
"prefs_notifications_delete_after_title": "Apagar notificações",
|
||||
"prefs_notifications_delete_after_never": "Nunca",
|
||||
"prefs_notifications_delete_after_never_description": "Notificações nunca serão auto excluídas",
|
||||
"prefs_users_description": "Adicionar/remover usuários em seus tópicos protegidos. Note que o usuário e senha são salvos no armazenamento local do navegador.",
|
||||
"prefs_users_add_button": "Adicionar usuário",
|
||||
"prefs_users_table_user_header": "Usuário",
|
||||
"prefs_users_table_base_url_header": "URL de serviço",
|
||||
"prefs_users_dialog_title_add": "Adicionar usuário",
|
||||
"prefs_users_dialog_title_edit": "Editar usuário",
|
||||
"prefs_users_dialog_base_url_label": "URL de serviço, exemplo https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Usuário, por exemplo phil",
|
||||
"prefs_users_dialog_password_label": "Senha",
|
||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
||||
"prefs_users_dialog_button_add": "Adicionar",
|
||||
"prefs_users_dialog_button_save": "Salvar",
|
||||
"prefs_appearance_title": "Aparência",
|
||||
"prefs_appearance_language_title": "LInguagem",
|
||||
"priority_min": "minima",
|
||||
"priority_low": "baixa",
|
||||
"priority_default": "padrão",
|
||||
"priority_high": "alta",
|
||||
"priority_max": "máxima",
|
||||
"error_boundary_title": "Ah não, ntfy parou de funcionar",
|
||||
"error_boundary_gathering_info": "Coletar mais informações …",
|
||||
"error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.<br/>Se tiver um minuto, por favor <githubLink> relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha",
|
||||
"error_boundary_stack_trace": "Rastreamento de pilha",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante",
|
||||
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restantes",
|
||||
"publish_dialog_priority_high": "Alta prioridade",
|
||||
"publish_dialog_priority_max": "Prioridade máxima",
|
||||
"publish_dialog_button_send": "Enviar",
|
||||
"publish_dialog_attached_file_title": "Arquivo anexado:",
|
||||
"publish_dialog_attach_label": "URL de anexo",
|
||||
"publish_dialog_chip_attach_url_label": "Anexar arquivo por URL",
|
||||
"publish_dialog_attach_placeholder": "Anexar arquivo por URL, por exemplo, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_chip_email_label": "Encaminhar para email",
|
||||
"publish_dialog_checkbox_publish_another": "Publicar outro",
|
||||
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todos os recursos de envio, consulte a <docsLink>documentação</docsLink>.",
|
||||
"publish_dialog_button_cancel": "Cancelar",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notificações são automaticamente excluídas após um dia",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notificações são automaticamente excluídas após um mês",
|
||||
"prefs_users_title": "Gerenciar usuários",
|
||||
"subscribe_dialog_error_user_not_authorized": "Usuário {{username}} não autorizado",
|
||||
"prefs_notifications_title": "Notificações",
|
||||
"prefs_notifications_sound_no_sound": "Sem som",
|
||||
"subscribe_dialog_login_title": "Login necessário",
|
||||
"prefs_notifications_sound_title": "Som de notificações",
|
||||
"prefs_notifications_min_priority_title": "Mínima prioridade",
|
||||
"prefs_notifications_min_priority_description_any": "Mostrando todas as notificações, independente da prioridade",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notificações são automaticamente excluídas após uma semana",
|
||||
"subscribe_dialog_login_description": "Esse tópico é protegido por senha. Por favor digite o nome de usuário e senha para inscrever.",
|
||||
"subscribe_dialog_login_username_label": "Nome, por exemplo phil",
|
||||
"subscribe_dialog_login_button_login": "Login",
|
||||
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
|
||||
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notificações são automaticamente excluídas após três horas",
|
||||
"publish_dialog_attach_reset": "Remover URL do anexo",
|
||||
"publish_dialog_emoji_picker_show": "Escolher emoji",
|
||||
"publish_dialog_attached_file_remove": "Remover arquivo anexado",
|
||||
"emoji_picker_search_clear": "Limpar",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL de subscrição",
|
||||
"notifications_list": "Lista de notificações",
|
||||
"message_bar_show_dialog": "Mostrar caixa de publicação",
|
||||
"publish_dialog_topic_reset": "Resetar tópico",
|
||||
"publish_dialog_delay_reset": "Remover entrega adiada da notificação",
|
||||
"nav_button_connecting": "Conectando",
|
||||
"publish_dialog_email_reset": "Remover encaminhar email",
|
||||
"prefs_notifications_sound_play": "Reproduzir som selecionado",
|
||||
"action_bar_show_menu": "Mostrar menu",
|
||||
"action_bar_toggle_mute": "Habilita/Desabilita notificações",
|
||||
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
|
||||
"action_bar_logo_alt": "nfty logo",
|
||||
"message_bar_publish": "Publicar mensagem",
|
||||
"nav_button_muted": "Notificações desabilitadas",
|
||||
"notifications_list_item": "Notificação",
|
||||
"notifications_mark_read": "Marcar como lido",
|
||||
"notifications_delete": "Excluir",
|
||||
"notifications_priority_x": "Prioridade {{priority}}",
|
||||
"notifications_new_indicator": "Nova notificação",
|
||||
"notifications_attachment_image": "Imagem anexada",
|
||||
"notifications_attachment_file_image": "Arquivo de imagem",
|
||||
"notifications_attachment_file_video": "Arquivo de vídeo",
|
||||
"notifications_attachment_file_audio": "Arquivo de áudio",
|
||||
"notifications_attachment_file_app": "Arquivo apk android",
|
||||
"notifications_attachment_file_document": "Outros documentos",
|
||||
"publish_dialog_click_reset": "Remover URL clicável",
|
||||
"prefs_users_table": "Tabela de usuários",
|
||||
"prefs_users_edit_button": "Editar usuário",
|
||||
"prefs_users_delete_button": "Excluir usuário",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada"
|
||||
}
|
||||
|
||||
@@ -150,5 +150,42 @@
|
||||
"priority_default": "öntanımlı",
|
||||
"prefs_notifications_min_priority_description_max": "Öncelik 5 (en fazla) ise bildirimleri göster",
|
||||
"prefs_notifications_delete_after_never_description": "Bildirimler asla otomatik olarak silinmez",
|
||||
"priority_high": "yüksek"
|
||||
"priority_high": "yüksek",
|
||||
"notifications_actions_not_supported": "Eylem, web uygulamasında desteklenmiyor",
|
||||
"notifications_actions_http_request_title": "{{url}} adresine HTTP {{method}} gönder",
|
||||
"action_bar_show_menu": "Menüyü göster",
|
||||
"action_bar_logo_alt": "ntfy logosu",
|
||||
"action_bar_toggle_action_menu": "Eylem menüsünü aç/kapat",
|
||||
"message_bar_show_dialog": "Yayınla iletişim kutusunu göster",
|
||||
"message_bar_publish": "Mesaj yayınla",
|
||||
"nav_button_connecting": "bağlanıyor",
|
||||
"notifications_list": "Bildirimler listesi",
|
||||
"notifications_list_item": "Bildirim",
|
||||
"notifications_delete": "Sil",
|
||||
"notifications_attachment_image": "Ek resmi",
|
||||
"notifications_attachment_file_image": "resim dosyası",
|
||||
"notifications_attachment_file_video": "video dosyası",
|
||||
"notifications_attachment_file_audio": "ses dosyası",
|
||||
"notifications_attachment_file_app": "Android uygulama dosyası",
|
||||
"notifications_attachment_file_document": "diğer belge",
|
||||
"publish_dialog_emoji_picker_show": "Emoji seç",
|
||||
"publish_dialog_topic_reset": "Konuyu sıfırla",
|
||||
"publish_dialog_attach_reset": "Ek URL'sini kaldır",
|
||||
"publish_dialog_delay_reset": "Gecikmeli teslimatı kaldır",
|
||||
"publish_dialog_attached_file_remove": "Ekli dosyayı kaldır",
|
||||
"emoji_picker_search_clear": "Aramayı temizle",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Hizmet URL'si",
|
||||
"prefs_notifications_sound_play": "Seçilen sesi çal",
|
||||
"error_boundary_unsupported_indexeddb_description": "ntfy web uygulamasının çalışması için IndexedDB'ye ihtiyacı var ve tarayıcınız gizli tarama modunda IndexedDB'yi desteklemiyor.<br/><br/>Bu talihsiz olsa da, ntfy web uygulamasını gizli tarama modunda kullanmak pek mantıklı değildir, çünkü her şey tarayıcı deposunda saklanır. <githubLink>Bu GitHub sorununda</githubLink> bununla ilgili daha fazla bilgi edinebilir veya <discordLink>Discord</discordLink> veya <matrixLink>Matrix</matrixLink> üzerinden bizimle konuşabilirsiniz.",
|
||||
"notifications_new_indicator": "Yeni bildirim",
|
||||
"action_bar_toggle_mute": "Bildirimleri sesini kapat/aç",
|
||||
"publish_dialog_click_reset": "Tıklama URL'sini kaldır",
|
||||
"prefs_users_table": "Kullanıcılar tablosu",
|
||||
"error_boundary_unsupported_indexeddb_title": "Gizli tarama desteklenmiyor",
|
||||
"nav_button_muted": "Bildirimler sessize alındı",
|
||||
"notifications_mark_read": "Okundu olarak işaretle",
|
||||
"notifications_priority_x": "Öncelik {{priority}}",
|
||||
"publish_dialog_email_reset": "E-posta yönlendirmesini kaldır",
|
||||
"prefs_users_edit_button": "Kullanıcıyı düzenle",
|
||||
"prefs_users_delete_button": "Kullanıcı sil"
|
||||
}
|
||||
|
||||
@@ -115,6 +115,12 @@ class SubscriptionManager {
|
||||
.delete();
|
||||
}
|
||||
|
||||
async markNotificationRead(notificationId) {
|
||||
await db.notifications
|
||||
.where({id: notificationId})
|
||||
.modify({new: 0});
|
||||
}
|
||||
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId, new: 1})
|
||||
|
||||
@@ -44,16 +44,22 @@ const ActionBar = (props) => {
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
aria-label={t("action_bar_show_menu")}
|
||||
onClick={props.onMobileDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box component="img" src={logo} sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
marginRight: '10px',
|
||||
height: '28px'
|
||||
}}/>
|
||||
<Box
|
||||
component="img"
|
||||
src={logo}
|
||||
alt={t("action_bar_logo_alt")}
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
marginRight: '10px',
|
||||
height: '28px'
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
@@ -173,10 +179,10 @@ const SettingsIcons = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}}>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
|
||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen}>
|
||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
||||
<MoreVertIcon/>
|
||||
</IconButton>
|
||||
<Popper
|
||||
|
||||
@@ -5,27 +5,36 @@ 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";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
const AttachmentIcon = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const type = props.type;
|
||||
let imageFile;
|
||||
let imageFile, imageLabel;
|
||||
if (!type) {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_image");
|
||||
} else if (type.startsWith('image/')) {
|
||||
imageFile = fileImage;
|
||||
imageLabel = t("notifications_attachment_file_video");
|
||||
} else if (type.startsWith('video/')) {
|
||||
imageFile = fileVideo;
|
||||
imageLabel = t("notifications_attachment_file_video");
|
||||
} else if (type.startsWith('audio/')) {
|
||||
imageFile = fileAudio;
|
||||
imageLabel = t("notifications_attachment_file_audio");
|
||||
} else if (type === "application/vnd.android.package-archive") {
|
||||
imageFile = fileApp;
|
||||
imageLabel = t("notifications_attachment_file_app");
|
||||
} else {
|
||||
imageFile = fileDocument;
|
||||
imageLabel = t("notifications_attachment_file_document");
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: '28px',
|
||||
|
||||
@@ -12,11 +12,15 @@ const DialogFooter = (props) => {
|
||||
paddingLeft: '24px',
|
||||
paddingBottom: '8px',
|
||||
}}>
|
||||
<DialogContentText component="div" sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}>
|
||||
<DialogContentText
|
||||
component="div"
|
||||
aria-live="polite"
|
||||
sx={{
|
||||
margin: '0px',
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '4px'
|
||||
}}
|
||||
>
|
||||
{props.status}
|
||||
</DialogContentText>
|
||||
<DialogActions sx={{paddingRight: 2}}>
|
||||
|
||||
@@ -80,10 +80,16 @@ const EmojiPicker = (props) => {
|
||||
variant="standard"
|
||||
fullWidth
|
||||
sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }}
|
||||
inputProps={{
|
||||
role: "searchbox",
|
||||
"aria-label": t("emoji_picker_search_placeholder")
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment:
|
||||
<InputAdornment position="end" sx={{ display: (search) ? '' : 'none' }}>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end"><Close/></IconButton>
|
||||
<IconButton size="small" onClick={handleSearchClear} edge="end" aria-label={t("emoji_picker_search_clear")}>
|
||||
<Close/>
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}}
|
||||
/>
|
||||
@@ -130,10 +136,12 @@ const Category = (props) => {
|
||||
const Emoji = (props) => {
|
||||
const emoji = props.emoji;
|
||||
const matches = emojiMatches(emoji, props.search);
|
||||
const title = `${emoji.description} (${emoji.aliases[0]})`;
|
||||
return (
|
||||
<EmojiDiv
|
||||
onClick={props.onClick}
|
||||
title={`${emoji.description} (${emoji.aliases[0]})`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{ display: (matches) ? '' : 'none' }}
|
||||
>
|
||||
{props.emoji.emoji}
|
||||
|
||||
@@ -10,13 +10,28 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
this.state = {
|
||||
error: false,
|
||||
originalStack: null,
|
||||
niceStack: null
|
||||
niceStack: null,
|
||||
unsupportedIndexedDB: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error("[ErrorBoundary] Error caught", error, info);
|
||||
|
||||
// Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see
|
||||
// - https://github.com/dexie/Dexie.js/issues/312
|
||||
// - https://bugzilla.mozilla.org/show_bug.cgi?id=781982
|
||||
const isUnsupportedIndexedDB = error?.name === "InvalidStateError" ||
|
||||
(error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1);
|
||||
|
||||
if (isUnsupportedIndexedDB) {
|
||||
this.handleUnsupportedIndexedDB();
|
||||
} else {
|
||||
this.handleError(error, info);
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error, info) {
|
||||
// Immediately render original stack trace
|
||||
const prettierOriginalStack = info.componentStack
|
||||
.trim()
|
||||
@@ -36,6 +51,13 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
handleUnsupportedIndexedDB() {
|
||||
this.setState({
|
||||
error: true,
|
||||
unsupportedIndexedDB: true
|
||||
});
|
||||
}
|
||||
|
||||
copyStack() {
|
||||
let stack = "";
|
||||
if (this.state.niceStack) {
|
||||
@@ -46,34 +68,61 @@ class ErrorBoundaryImpl extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_title")} 😮</h2>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="error_boundary_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
||||
</p>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
if (this.state.unsupportedIndexedDB) {
|
||||
return this.renderUnsupportedIndexedDB();
|
||||
} else {
|
||||
return this.renderError();
|
||||
}
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
renderUnsupportedIndexedDB() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_unsupported_indexeddb_title")} 😮</h2>
|
||||
<p style={{maxWidth: "600px"}}>
|
||||
<Trans
|
||||
i18nKey="error_boundary_unsupported_indexeddb_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues/208"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const { t } = this.props;
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>{t("error_boundary_title")} 😮</h2>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="error_boundary_description"
|
||||
components={{
|
||||
githubLink: <Link href="https://github.com/binwiederhier/ntfy/issues"/>,
|
||||
discordLink: <Link href="https://discord.gg/cT7ECsZj9w"/>,
|
||||
matrixLink: <Link href="https://matrix.to/#/#ntfy:matrix.org"/>
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>{t("error_boundary_button_copy_stack_trace")}</Button>
|
||||
</p>
|
||||
<h3>{t("error_boundary_stack_trace")}</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> {t("error_boundary_gathering_info")}</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t
|
||||
|
||||
@@ -75,13 +75,15 @@ const MessageBar = (props) => {
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
||||
}}
|
||||
>
|
||||
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick}>
|
||||
<IconButton color="inherit" size="large" edge="start" onClick={props.onOpenDialogClick} aria-label={t("message_bar_show_dialog")}>
|
||||
<KeyboardArrowUpIcon/>
|
||||
</IconButton>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
placeholder={t("message_bar_type_message")}
|
||||
aria-label={t("message_bar_type_message")}
|
||||
role="textbox"
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
@@ -94,7 +96,7 @@ const MessageBar = (props) => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick}>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
|
||||
<SendIcon/>
|
||||
</IconButton>
|
||||
<Portal>
|
||||
|
||||
@@ -31,10 +31,15 @@ const navWidth = 280;
|
||||
const Navigation = (props) => {
|
||||
const navigationList = <NavList {...props}/>;
|
||||
return (
|
||||
<Box component="nav" sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}>
|
||||
<Box
|
||||
component="nav"
|
||||
role="navigation"
|
||||
sx={{width: {sm: Navigation.width}, flexShrink: {sm: 0}}}
|
||||
>
|
||||
{/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
role="menubar"
|
||||
open={props.mobileDrawerOpen}
|
||||
onClose={props.onMobileDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }} // Better open performance on mobile.
|
||||
@@ -49,6 +54,7 @@ const Navigation = (props) => {
|
||||
<Drawer
|
||||
open
|
||||
variant="permanent"
|
||||
role="menubar"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: navWidth },
|
||||
@@ -157,6 +163,7 @@ const SubscriptionList = (props) => {
|
||||
}
|
||||
|
||||
const SubscriptionItem = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const subscription = props.subscription;
|
||||
const iconBadge = (subscription.new <= 99) ? subscription.new : "99+";
|
||||
@@ -166,16 +173,19 @@ const SubscriptionItem = (props) => {
|
||||
const label = (subscription.baseUrl === window.location.origin)
|
||||
? subscription.topic
|
||||
: topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const ariaLabel = (subscription.state === ConnectionState.Connecting)
|
||||
? `${label} (${t("nav_button_connecting")})`
|
||||
: label;
|
||||
const handleClick = async () => {
|
||||
navigate(routes.forSubscription(subscription));
|
||||
await subscriptionManager.markNotificationsRead(subscription.id);
|
||||
};
|
||||
return (
|
||||
<ListItemButton onClick={handleClick} selected={props.selected}>
|
||||
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText primary={label}/>
|
||||
{subscription.mutedUntil > 0 &&
|
||||
<ListItemIcon edge="end"><NotificationsOffOutlined /></ListItemIcon>}
|
||||
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
|
||||
</ListItemButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
unmatchedTags
|
||||
} from "../app/utils";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles";
|
||||
import {useLiveQuery} from "dexie-react-hooks";
|
||||
@@ -98,6 +99,8 @@ const NotificationList = (props) => {
|
||||
>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
role="list"
|
||||
aria-label={t("notifications_list")}
|
||||
sx={{
|
||||
marginTop: 3,
|
||||
marginBottom: (props.messageBar) ? "100px" : 3 // Hack to avoid hiding notifications behind the message bar
|
||||
@@ -133,6 +136,10 @@ const NotificationItem = (props) => {
|
||||
console.log(`[Notifications] Deleting notification ${notification.id}`);
|
||||
await subscriptionManager.deleteNotification(notification.id)
|
||||
}
|
||||
const handleMarkRead = async () => {
|
||||
console.log(`[Notifications] Marking notification ${notification.id} as read`);
|
||||
await subscriptionManager.markNotificationRead(notification.id)
|
||||
}
|
||||
const handleCopy = (s) => {
|
||||
navigator.clipboard.writeText(s);
|
||||
props.onShowSnack();
|
||||
@@ -143,25 +150,33 @@ const NotificationItem = (props) => {
|
||||
const hasUserActions = notification.actions && notification.actions.length > 0;
|
||||
const showActions = hasAttachmentActions || hasClickAction || hasUserActions;
|
||||
return (
|
||||
<Card sx={{ minWidth: 275, padding: 1 }}>
|
||||
<Card sx={{ minWidth: 275, padding: 1 }} role="listitem" aria-label={t("notifications_list_item")}>
|
||||
<CardContent>
|
||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Tooltip title={t("notifications_delete")} enterDelay={500}>
|
||||
<IconButton onClick={handleDelete} sx={{ float: 'right', marginRight: -1, marginTop: -1 }} aria-label={t("notifications_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{notification.new === 1 &&
|
||||
<Tooltip title={t("notifications_mark_read")} enterDelay={500}>
|
||||
<IconButton onClick={handleMarkRead} sx={{ float: 'right', marginRight: -0.5, marginTop: -1 }} aria-label={t("notifications_mark_read")}>
|
||||
<CheckIcon />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
<Typography sx={{ fontSize: 14 }} color="text.secondary">
|
||||
{date}
|
||||
{[1,2,4,5].includes(notification.priority) &&
|
||||
<img
|
||||
src={priorityFiles[notification.priority]}
|
||||
alt={`Priority ${notification.priority}`}
|
||||
alt={t("notifications_priority_x", { priority: notification.priority})}
|
||||
style={{ verticalAlign: 'bottom' }}
|
||||
/>}
|
||||
{notification.new === 1 &&
|
||||
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg style={{ width: '8px', height: '8px', marginLeft: '4px' }} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-label={t("notifications_new_indicator")}>
|
||||
<circle cx="50" cy="50" r="50" fill="#338574"/>
|
||||
</svg>}
|
||||
</Typography>
|
||||
{notification.title && <Typography variant="h5" component="div">{formatTitle(notification)}</Typography>}
|
||||
{notification.title && <Typography variant="h5" component="div" role="rowheader">{formatTitle(notification)}</Typography>}
|
||||
<Typography variant="body1" sx={{ whiteSpace: 'pre-line' }}>
|
||||
{autolink(maybeAppendActionErrors(formatMessage(notification), notification))}
|
||||
</Typography>
|
||||
@@ -289,6 +304,7 @@ const Attachment = (props) => {
|
||||
};
|
||||
|
||||
const Image = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
@@ -296,6 +312,7 @@ const Image = (props) => {
|
||||
component="img"
|
||||
src={props.attachment.url}
|
||||
loading="lazy"
|
||||
alt={t("notifications_attachment_image")}
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
@@ -316,6 +333,7 @@ const Image = (props) => {
|
||||
<Box
|
||||
component="img"
|
||||
src={props.attachment.url}
|
||||
alt={t("notifications_attachment_image")}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
maxWidth: 1,
|
||||
@@ -347,13 +365,16 @@ const UserAction = (props) => {
|
||||
if (action.action === "broadcast") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_not_supported")}>
|
||||
<span><Button disabled>{action.label}</Button></span>
|
||||
<span><Button disabled aria-label={t("notifications_actions_not_supported")}>{action.label}</Button></span>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "view") {
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_open_url_title", { url: action.url })}>
|
||||
<Button onClick={() => openUrl(action.url)}>{action.label}</Button>
|
||||
<Button
|
||||
onClick={() => openUrl(action.url)}
|
||||
aria-label={t("notifications_actions_open_url_title", { url: action.url })}
|
||||
>{action.label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
} else if (action.action === "http") {
|
||||
@@ -361,7 +382,10 @@ const UserAction = (props) => {
|
||||
const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? "");
|
||||
return (
|
||||
<Tooltip title={t("notifications_actions_http_request_title", { method: method, url: action.url })}>
|
||||
<Button onClick={() => performHttpAction(notification, action)}>{label}</Button>
|
||||
<Button
|
||||
onClick={() => performHttpAction(notification, action)}
|
||||
aria-label={t("notifications_actions_http_request_title", { method: method, url: action.url })}
|
||||
>{label}</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -416,7 +440,7 @@ const NoNotifications = (props) => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_none_for_topic_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
@@ -442,7 +466,7 @@ const NoNotificationsWithoutSubscription = (props) => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_none_for_any_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
@@ -466,7 +490,7 @@ const NoSubscriptions = () => {
|
||||
return (
|
||||
<VerticallyCenteredContainer maxWidth="xs">
|
||||
<Typography variant="h5" align="center" sx={{ paddingBottom: 1 }}>
|
||||
<img src={logoOutline} height="64" width="64"/><br />
|
||||
<img src={logoOutline} height="64" width="64" alt={t("action_bar_logo_alt")}/><br />
|
||||
{t("notifications_no_subscriptions_title")}
|
||||
</Typography>
|
||||
<Paragraph>
|
||||
|
||||
@@ -34,11 +34,6 @@ import DialogActions from "@mui/material/DialogActions";
|
||||
import userManager from "../app/UserManager";
|
||||
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
|
||||
import {useTranslation} from "react-i18next";
|
||||
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";
|
||||
|
||||
const Preferences = () => {
|
||||
return (
|
||||
@@ -55,7 +50,7 @@ const Preferences = () => {
|
||||
const Notifications = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Card sx={{p: 3}} aria-label={t("prefs_notifications_title")}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_notifications_title")}
|
||||
</Typography>
|
||||
@@ -70,6 +65,7 @@ const Notifications = () => {
|
||||
|
||||
const Sound = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefSound";
|
||||
const sound = useLiveQuery(async () => prefs.sound());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setSound(ev.target.value);
|
||||
@@ -84,15 +80,15 @@ const Sound = () => {
|
||||
description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label });
|
||||
}
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_sound_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_sound_title")} description={description}>
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<FormControl fullWidth variant="standard" sx={{ margin: 1 }}>
|
||||
<Select value={sound} onChange={handleChange}>
|
||||
<Select value={sound} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={"none"}>{t("prefs_notifications_sound_no_sound")}</MenuItem>
|
||||
{Object.entries(sounds).map(s => <MenuItem key={s[0]} value={s[0]}>{s[1].label}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"}>
|
||||
<IconButton onClick={() => playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}>
|
||||
<PlayArrowIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
@@ -102,6 +98,7 @@ const Sound = () => {
|
||||
|
||||
const MinPriority = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefMinPriority";
|
||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setMinPriority(ev.target.value);
|
||||
@@ -128,9 +125,9 @@ const MinPriority = () => {
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_min_priority_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_min_priority_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={minPriority} onChange={handleChange}>
|
||||
<Select value={minPriority} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={1}>{t("prefs_notifications_min_priority_any")}</MenuItem>
|
||||
<MenuItem value={2}>{t("prefs_notifications_min_priority_low_and_higher")}</MenuItem>
|
||||
<MenuItem value={3}>{t("prefs_notifications_min_priority_default_and_higher")}</MenuItem>
|
||||
@@ -144,6 +141,7 @@ const MinPriority = () => {
|
||||
|
||||
const DeleteAfter = () => {
|
||||
const { t } = useTranslation();
|
||||
const labelId = "prefDeleteAfter";
|
||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||
const handleChange = async (ev) => {
|
||||
await prefs.setDeleteAfter(ev.target.value);
|
||||
@@ -161,9 +159,9 @@ const DeleteAfter = () => {
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<Pref title={t("prefs_notifications_delete_after_title")} description={description}>
|
||||
<Pref labelId={labelId} title={t("prefs_notifications_delete_after_title")} description={description}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={deleteAfter} onChange={handleChange}>
|
||||
<Select value={deleteAfter} onChange={handleChange} aria-labelledby={labelId}>
|
||||
<MenuItem value={0}>{t("prefs_notifications_delete_after_never")}</MenuItem>
|
||||
<MenuItem value={10800}>{t("prefs_notifications_delete_after_three_hours")}</MenuItem>
|
||||
<MenuItem value={86400}>{t("prefs_notifications_delete_after_one_day")}</MenuItem>
|
||||
@@ -177,7 +175,7 @@ const DeleteAfter = () => {
|
||||
|
||||
const PrefGroup = (props) => {
|
||||
return (
|
||||
<div>
|
||||
<div role="table">
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
@@ -185,28 +183,39 @@ const PrefGroup = (props) => {
|
||||
|
||||
const Pref = (props) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
marginTop: "10px",
|
||||
marginBottom: "20px",
|
||||
}}>
|
||||
<div style={{
|
||||
flex: '1 0 40%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingRight: '30px'
|
||||
}}>
|
||||
<div
|
||||
role="row"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
marginTop: "10px",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="cell"
|
||||
id={props.labelId}
|
||||
aria-label={props.title}
|
||||
style={{
|
||||
flex: '1 0 40%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingRight: '30px'
|
||||
}}
|
||||
>
|
||||
<div><b>{props.title}</b></div>
|
||||
{props.description && <div><em>{props.description}</em></div>}
|
||||
</div>
|
||||
<div style={{
|
||||
flex: '1 0 calc(60% - 50px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div
|
||||
role="cell"
|
||||
style={{
|
||||
flex: '1 0 calc(60% - 50px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,7 +244,7 @@ const Users = () => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card sx={{ padding: 1 }}>
|
||||
<Card sx={{ padding: 1 }} aria-label={t("prefs_users_title")}>
|
||||
<CardContent sx={{ paddingBottom: 1 }}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_users_title")}
|
||||
@@ -291,7 +300,7 @@ const UserTable = (props) => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Table size="small">
|
||||
<Table size="small" aria-label={t("prefs_users_table")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{paddingLeft: 0}}>{t("prefs_users_table_user_header")}</TableCell>
|
||||
@@ -305,13 +314,13 @@ const UserTable = (props) => {
|
||||
key={user.baseUrl}
|
||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
||||
>
|
||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}>{user.username}</TableCell>
|
||||
<TableCell>{user.baseUrl}</TableCell>
|
||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
|
||||
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={() => handleEditClick(user)}>
|
||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
||||
<EditIcon/>
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleDeleteClick(user)}>
|
||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
@@ -371,6 +380,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="baseUrl"
|
||||
label={t("prefs_users_dialog_base_url_label")}
|
||||
aria-label={t("prefs_users_dialog_base_url_label")}
|
||||
value={baseUrl}
|
||||
onChange={ev => setBaseUrl(ev.target.value)}
|
||||
type="url"
|
||||
@@ -382,6 +392,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="username"
|
||||
label={t("prefs_users_dialog_username_label")}
|
||||
aria-label={t("prefs_users_dialog_username_label")}
|
||||
value={username}
|
||||
onChange={ev => setUsername(ev.target.value)}
|
||||
type="text"
|
||||
@@ -392,6 +403,7 @@ const UserDialog = (props) => {
|
||||
margin="dense"
|
||||
id="password"
|
||||
label={t("prefs_users_dialog_password_label")}
|
||||
aria-label={t("prefs_users_dialog_password_label")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
@@ -410,7 +422,7 @@ const UserDialog = (props) => {
|
||||
const Appearance = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card sx={{p: 3}}>
|
||||
<Card sx={{p: 3}} aria-label={t("prefs_appearance_title")}>
|
||||
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||
{t("prefs_appearance_title")}
|
||||
</Typography>
|
||||
@@ -423,24 +435,30 @@ const Appearance = () => {
|
||||
|
||||
const Language = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇧🇬", "🇩🇪", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
||||
const labelId = "prefLanguage";
|
||||
const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇭🇺", "🇧🇷", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3);
|
||||
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
||||
const lang = i18n.language ?? "en";
|
||||
|
||||
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
||||
// Languages names from: https://www.omniglot.com/language/names.htm
|
||||
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
|
||||
|
||||
return (
|
||||
<Pref title={title}>
|
||||
<Pref labelId={labelId} title={title}>
|
||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||
<Select value={i18n.language} onChange={(ev) => i18n.changeLanguage(ev.target.value)}>
|
||||
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
<MenuItem value="bg">Български</MenuItem>
|
||||
<MenuItem value="cs">Čeština</MenuItem>
|
||||
<MenuItem value="de">Deutsch</MenuItem>
|
||||
<MenuItem value="es">Español</MenuItem>
|
||||
<MenuItem value="fr">Français</MenuItem>
|
||||
<MenuItem value="hu">Magyar</MenuItem>
|
||||
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
|
||||
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
|
||||
<MenuItem value="ru">Русский</MenuItem>
|
||||
<MenuItem value="tr">Türkçe</MenuItem>
|
||||
</Select>
|
||||
|
||||
@@ -232,7 +232,7 @@ const PublishDialog = (props) => {
|
||||
<DialogContent>
|
||||
{dropZone && <DropBox/>}
|
||||
{showTopicUrl &&
|
||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} onClose={() => {
|
||||
<ClosableRow closable={!!props.baseUrl && !!props.topic} disabled={disabled} closeLabel={t("publish_dialog_topic_reset")} onClose={() => {
|
||||
setBaseUrl(props.baseUrl);
|
||||
setTopic(props.topic);
|
||||
setShowTopicUrl(false);
|
||||
@@ -247,6 +247,9 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_base_url_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -259,6 +262,9 @@ const PublishDialog = (props) => {
|
||||
variant="standard"
|
||||
autoFocus={!messageFocused}
|
||||
sx={{flexGrow: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_topic_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -272,6 +278,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_title_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -286,6 +295,9 @@ const PublishDialog = (props) => {
|
||||
autoFocus={messageFocused}
|
||||
fullWidth
|
||||
multiline
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_message_label")
|
||||
}}
|
||||
/>
|
||||
<div style={{display: 'flex'}}>
|
||||
<EmojiPicker
|
||||
@@ -293,7 +305,7 @@ const PublishDialog = (props) => {
|
||||
onEmojiPick={handleEmojiPick}
|
||||
onClose={handleEmojiClose}
|
||||
/>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick}>
|
||||
<DialogIconButton disabled={disabled} onClick={handleEmojiClick} aria-label={t("publish_dialog_emoji_picker_show")}>
|
||||
<InsertEmoticonIcon/>
|
||||
</DialogIconButton>
|
||||
<TextField
|
||||
@@ -306,6 +318,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_tags_label")
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
variant="standard"
|
||||
@@ -319,11 +334,14 @@ const PublishDialog = (props) => {
|
||||
value={priority}
|
||||
onChange={(ev) => setPriority(ev.target.value)}
|
||||
disabled={disabled}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_priority_label")
|
||||
}}
|
||||
>
|
||||
{[5,4,3,2,1].map(priority =>
|
||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority}>
|
||||
<MenuItem key={`priorityMenuItem${priority}`} value={priority} aria-label={t("notifications_priority_x", { priority: priority })}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img src={priorities[priority].file} style={{marginRight: "8px"}}/>
|
||||
<img src={priorities[priority].file} style={{marginRight: "8px"}} alt={t("notifications_priority_x", { priority: priority })}/>
|
||||
<div>{priorities[priority].label}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
@@ -332,7 +350,7 @@ const PublishDialog = (props) => {
|
||||
</FormControl>
|
||||
</div>
|
||||
{showClickUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_click_reset")} onClose={() => {
|
||||
setClickUrl("");
|
||||
setShowClickUrl(false);
|
||||
}}>
|
||||
@@ -346,11 +364,14 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_click_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showEmail &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_email_reset")} onClose={() => {
|
||||
setEmail("");
|
||||
setShowEmail(false);
|
||||
}}>
|
||||
@@ -364,11 +385,14 @@ const PublishDialog = (props) => {
|
||||
type="email"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_email_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
{showAttachUrl &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_attach_reset")} onClose={() => {
|
||||
setAttachUrl("");
|
||||
setFilename("");
|
||||
setFilenameEdited(false);
|
||||
@@ -398,6 +422,9 @@ const PublishDialog = (props) => {
|
||||
type="url"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 5, marginRight: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_attach_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -412,6 +439,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
sx={{flexGrow: 1}}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_filename_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -420,6 +450,7 @@ const PublishDialog = (props) => {
|
||||
ref={attachFileInput}
|
||||
onChange={handleAttachFileChanged}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
{showAttachFile && <AttachmentBox
|
||||
file={attachFile}
|
||||
@@ -434,7 +465,7 @@ const PublishDialog = (props) => {
|
||||
}}
|
||||
/>}
|
||||
{showDelay &&
|
||||
<ClosableRow disabled={disabled} onClose={() => {
|
||||
<ClosableRow disabled={disabled} closeLabel={t("publish_dialog_delay_reset")} onClose={() => {
|
||||
setDelay("");
|
||||
setShowDelay(false);
|
||||
}}>
|
||||
@@ -452,6 +483,9 @@ const PublishDialog = (props) => {
|
||||
type="text"
|
||||
variant="standard"
|
||||
fullWidth
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_delay_label")
|
||||
}}
|
||||
/>
|
||||
</ClosableRow>
|
||||
}
|
||||
@@ -459,12 +493,12 @@ const PublishDialog = (props) => {
|
||||
{t("publish_dialog_other_features")}
|
||||
</Typography>
|
||||
<div>
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showClickUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_click_label")} aria-label={t("publish_dialog_chip_click_label")} onClick={() => setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showEmail && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_email_label")} aria-label={t("publish_dialog_chip_email_label")} onClick={() => setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachUrl && !showAttachFile && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_url_label")} aria-label={t("publish_dialog_chip_attach_url_label")} onClick={() => setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showAttachFile && !showAttachUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_attach_file_label")} aria-label={t("publish_dialog_chip_attach_file_label")} onClick={() => handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showDelay && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_delay_label")} aria-label={t("publish_dialog_chip_delay_label")} onClick={() => setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
{!showTopicUrl && <Chip clickable disabled={disabled} label={t("publish_dialog_chip_topic_label")} aria-label={t("publish_dialog_chip_topic_label")} onClick={() => setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>}
|
||||
</div>
|
||||
<Typography variant="body1" sx={{marginTop: 1, marginBottom: 1}}>
|
||||
<Trans
|
||||
@@ -483,7 +517,13 @@ const PublishDialog = (props) => {
|
||||
label={t("publish_dialog_checkbox_publish_another")}
|
||||
sx={{marginRight: 2}}
|
||||
control={
|
||||
<Checkbox size="small" checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} />
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={publishAnother}
|
||||
onChange={(ev) => setPublishAnother(ev.target.checked)}
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_checkbox_publish_another")
|
||||
}} />
|
||||
} />
|
||||
<Button onClick={props.onClose}>{t("publish_dialog_button_cancel")}</Button>
|
||||
<Button onClick={handleSubmit} disabled={!sendButtonEnabled}>{t("publish_dialog_button_send")}</Button>
|
||||
@@ -497,7 +537,7 @@ const PublishDialog = (props) => {
|
||||
|
||||
const Row = (props) => {
|
||||
return (
|
||||
<div style={{display: 'flex'}}>
|
||||
<div style={{display: 'flex'}} role="row">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
@@ -508,7 +548,11 @@ const ClosableRow = (props) => {
|
||||
return (
|
||||
<Row>
|
||||
{props.children}
|
||||
{closable && <DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>}
|
||||
{closable &&
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={props.closeLabel}>
|
||||
<Close/>
|
||||
</DialogIconButton>
|
||||
}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
@@ -523,6 +567,7 @@ const DialogIconButton = (props) => {
|
||||
sx={{height: "45px", marginTop: "17px", ...sx}}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
{props.children}
|
||||
</IconButton>
|
||||
@@ -557,13 +602,15 @@ const AttachmentBox = (props) => {
|
||||
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||
{formatBytes(file.size)}
|
||||
{props.error &&
|
||||
<Typography component="span" sx={{ color: 'error.main' }}>
|
||||
<Typography component="span" sx={{ color: 'error.main' }} aria-live="polite">
|
||||
{" "}({props.error})
|
||||
</Typography>
|
||||
}
|
||||
</Typography>
|
||||
</Box>
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}}><Close/></DialogIconButton>
|
||||
<DialogIconButton disabled={props.disabled} onClick={props.onClose} sx={{marginLeft: "6px"}} aria-label={t("publish_dialog_attached_file_remove")}>
|
||||
<Close/>
|
||||
</DialogIconButton>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -588,6 +635,7 @@ const ExpandingTextField = (props) => {
|
||||
ref={invisibleFieldRef}
|
||||
component="span"
|
||||
variant={props.variant}
|
||||
aria-hidden={true}
|
||||
sx={{position: "absolute", left: "-200%"}}
|
||||
>
|
||||
{props.value}
|
||||
@@ -601,7 +649,10 @@ const ExpandingTextField = (props) => {
|
||||
variant="standard"
|
||||
sx={{ width: `${textWidth}px`, borderBottom: "none" }}
|
||||
InputProps={{ style: { fontSize: theme.typography[props.variant].fontSize } }}
|
||||
inputProps={{ style: { paddingBottom: 0, paddingTop: 0 } }}
|
||||
inputProps={{
|
||||
style: { paddingBottom: 0, paddingTop: 0 },
|
||||
"aria-label": props.placeholder
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -102,16 +102,26 @@ const SubscribePage = (props) => {
|
||||
margin="dense"
|
||||
id="topic"
|
||||
placeholder={t("subscribe_dialog_subscribe_topic_placeholder")}
|
||||
inputProps={{ maxLength: 64 }}
|
||||
value={props.topic}
|
||||
onChange={ev => props.setTopic(ev.target.value)}
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
maxLength: 64,
|
||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
sx={{pt: 1}}
|
||||
control={<Checkbox onChange={handleUseAnotherChanged}/>}
|
||||
control={
|
||||
<Checkbox
|
||||
onChange={handleUseAnotherChanged}
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("subscribe_dialog_subscribe_use_another_label")} />
|
||||
{anotherServerVisible && <Autocomplete
|
||||
freeSolo
|
||||
@@ -120,7 +130,12 @@ const SubscribePage = (props) => {
|
||||
inputValue={props.baseUrl}
|
||||
onInputChange={(ev, newVal) => props.setBaseUrl(newVal)}
|
||||
renderInput={ (params) =>
|
||||
<TextField {...params} placeholder={window.location.origin} variant="standard"/>
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder={window.location.origin}
|
||||
variant="standard"
|
||||
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
||||
/>
|
||||
}
|
||||
/>}
|
||||
</DialogContent>
|
||||
@@ -168,6 +183,9 @@ const LoginPage = (props) => {
|
||||
type="text"
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_username_label")
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="dense"
|
||||
@@ -178,6 +196,9 @@ const LoginPage = (props) => {
|
||||
onChange={ev => setPassword(ev.target.value)}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
inputProps={{
|
||||
"aria-label": t("subscribe_dialog_login_password_label")
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter status={errorText}>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user