Compare commits
104 Commits
ipv6
...
http-clipb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb386af58 | ||
|
|
b105ed6727 | ||
|
|
965110b2c3 | ||
|
|
c8ac104043 | ||
|
|
e39498702d | ||
|
|
9b97067b10 | ||
|
|
5244e0be14 | ||
|
|
6eb25f68ac | ||
|
|
efe7c3fa70 | ||
|
|
ce4b2ae9a0 | ||
|
|
ba86e08ffe | ||
|
|
2d9e2356b1 | ||
|
|
fe5c844a21 | ||
|
|
97410db301 | ||
|
|
887751cd5d | ||
|
|
044326068c | ||
|
|
57a51ab2da | ||
|
|
998dbd9054 | ||
|
|
a5a55bd43a | ||
|
|
00409d834b | ||
|
|
d9ab7cc78d | ||
|
|
99a2ca8802 | ||
|
|
ea338ae4fa | ||
|
|
32fa8d43c1 | ||
|
|
0f166e0a1d | ||
|
|
46e423fc40 | ||
|
|
eac523dcf9 | ||
|
|
4225ce2f42 | ||
|
|
d35dfc14d1 | ||
|
|
cef228f880 | ||
|
|
bcfb50b35a | ||
|
|
c4c4916bc8 | ||
|
|
81463614c9 | ||
|
|
15a7f86344 | ||
|
|
3c1da90f47 | ||
|
|
27151d1cac | ||
|
|
a1c6dd2085 | ||
|
|
8f930acfb8 | ||
|
|
08d44703c3 | ||
|
|
82282419fe | ||
|
|
e290d1307f | ||
|
|
747c5c9fff | ||
|
|
9f987e66fa | ||
|
|
b91ff5f0b5 | ||
|
|
23ec7702fc | ||
|
|
f8082d9481 | ||
|
|
d9ecee7200 | ||
|
|
149c13e9d8 | ||
|
|
07e9670a09 | ||
|
|
0e67228605 | ||
|
|
2578236d8d | ||
|
|
fe545423c5 | ||
|
|
f3c67f1d71 | ||
|
|
27b3a89247 | ||
|
|
1470afb715 | ||
|
|
b495a744c9 | ||
|
|
d2b5917e2b | ||
|
|
52ca98611c | ||
|
|
1b394e9bb8 | ||
|
|
0d36ab8af3 | ||
|
|
141ddb3a51 | ||
|
|
f99801a2e6 | ||
|
|
4457e9e26f | ||
|
|
4eb7dc563c | ||
|
|
269373d75d | ||
|
|
ef275ac0c1 | ||
|
|
f59df0f40a | ||
|
|
214f70e62f | ||
|
|
51af114b2e | ||
|
|
83bf9d4d6c | ||
|
|
f298d947bd | ||
|
|
d87d8a2db4 | ||
|
|
50c564d8a2 | ||
|
|
c807b5db21 | ||
|
|
4d1baae6d0 | ||
|
|
34bc551303 | ||
|
|
0847a6406e | ||
|
|
006f73af7d | ||
|
|
f4a74dac57 | ||
|
|
1f34c39eb0 | ||
|
|
8783c86cd6 | ||
|
|
892e82ceb8 | ||
|
|
8b4834929d | ||
|
|
f0d5392e9e | ||
|
|
dde07adbdc | ||
|
|
57df16dd62 | ||
|
|
ae62e0d955 | ||
|
|
4603802f62 | ||
|
|
610792b902 | ||
|
|
b1e935da45 | ||
|
|
93e14b73bb | ||
|
|
81a486adc1 | ||
|
|
8bf4727a1c | ||
|
|
2a468493f9 | ||
|
|
3ac3e2ec7c | ||
|
|
fea0f301d2 | ||
|
|
1ce08a18c0 | ||
|
|
8d6f1eecdf | ||
|
|
c0b5151bae | ||
|
|
650f492d7d | ||
|
|
1f2c76e63d | ||
|
|
efef587671 | ||
|
|
3c8ac4a1e1 | ||
|
|
5ccc131e73 |
@@ -1,76 +1,70 @@
|
|||||||
|
version: 2
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod download
|
- go mod download
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
- id: ntfy_linux_amd64
|
||||||
id: ntfy_linux_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [ linux ]
|
||||||
goarch: [amd64]
|
goarch: [ amd64 ]
|
||||||
-
|
- id: ntfy_linux_armv6
|
||||||
id: ntfy_linux_armv6
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [ linux ]
|
||||||
goarch: [arm]
|
goarch: [ arm ]
|
||||||
goarm: [6]
|
goarm: [ 6 ]
|
||||||
-
|
- id: ntfy_linux_armv7
|
||||||
id: ntfy_linux_armv7
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [ linux ]
|
||||||
goarch: [arm]
|
goarch: [ arm ]
|
||||||
goarm: [7]
|
goarm: [ 7 ]
|
||||||
-
|
- id: ntfy_linux_arm64
|
||||||
id: ntfy_linux_arm64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=1 # required for go-sqlite3
|
- CGO_ENABLED=1 # required for go-sqlite3
|
||||||
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
|
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
|
||||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [ linux ]
|
||||||
goarch: [arm64]
|
goarch: [ arm64 ]
|
||||||
-
|
- id: ntfy_windows_amd64
|
||||||
id: ntfy_windows_amd64
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
tags: [noserver] # don't include server files
|
tags: [ noserver ] # don't include server files
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [windows]
|
goos: [ windows ]
|
||||||
goarch: [amd64]
|
goarch: [ amd64 ]
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||||
tags: [noserver] # don't include server files
|
tags: [ noserver ] # don't include server files
|
||||||
ldflags:
|
ldflags:
|
||||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [darwin]
|
goos: [ darwin ]
|
||||||
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
- package_name: ntfy
|
||||||
package_name: ntfy
|
|
||||||
homepage: https://heckel.io/ntfy
|
homepage: https://heckel.io/ntfy
|
||||||
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||||
description: Simple pub-sub notification service
|
description: Simple pub-sub notification service
|
||||||
@@ -106,9 +100,8 @@ nfpms:
|
|||||||
preremove: "scripts/prerm.sh"
|
preremove: "scripts/prerm.sh"
|
||||||
postremove: "scripts/postrm.sh"
|
postremove: "scripts/postrm.sh"
|
||||||
archives:
|
archives:
|
||||||
-
|
- id: ntfy_linux
|
||||||
id: ntfy_linux
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_linux_amd64
|
- ntfy_linux_amd64
|
||||||
- ntfy_linux_armv6
|
- ntfy_linux_armv6
|
||||||
- ntfy_linux_armv7
|
- ntfy_linux_armv7
|
||||||
@@ -122,19 +115,17 @@ archives:
|
|||||||
- client/client.yml
|
- client/client.yml
|
||||||
- client/ntfy-client.service
|
- client/ntfy-client.service
|
||||||
- client/user/ntfy-client.service
|
- client/user/ntfy-client.service
|
||||||
-
|
- id: ntfy_windows
|
||||||
id: ntfy_windows
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_windows_amd64
|
- ntfy_windows_amd64
|
||||||
format: zip
|
formats: [ zip ]
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
files:
|
files:
|
||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
-
|
- id: ntfy_darwin
|
||||||
id: ntfy_darwin
|
ids:
|
||||||
builds:
|
|
||||||
- ntfy_darwin_all
|
- ntfy_darwin_all
|
||||||
wrap_in_directory: true
|
wrap_in_directory: true
|
||||||
files:
|
files:
|
||||||
@@ -142,14 +133,13 @@ archives:
|
|||||||
- README.md
|
- README.md
|
||||||
- client/client.yml
|
- client/client.yml
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
-
|
- id: ntfy_darwin_all
|
||||||
id: ntfy_darwin_all
|
|
||||||
replace: true
|
replace: true
|
||||||
name_template: ntfy
|
name_template: ntfy
|
||||||
checksum:
|
checksum:
|
||||||
name_template: 'checksums.txt'
|
name_template: 'checksums.txt'
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
version_template: "{{ .Tag }}-next"
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -220,7 +220,7 @@ cli-deps-static-sites:
|
|||||||
touch server/docs/index.html server/site/app.html
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
cli-deps-all:
|
cli-deps-all:
|
||||||
go install github.com/goreleaser/goreleaser@latest
|
go install github.com/goreleaser/goreleaser/v2@latest
|
||||||
|
|
||||||
cli-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; }
|
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/ARMv7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||||
@@ -232,7 +232,7 @@ cli-deps-update:
|
|||||||
go get -u
|
go get -u
|
||||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
go install golang.org/x/lint/golint@latest
|
go install golang.org/x/lint/golint@latest
|
||||||
go install github.com/goreleaser/goreleaser@latest
|
go install github.com/goreleaser/goreleaser/v2@latest
|
||||||
|
|
||||||
cli-build-results:
|
cli-build-results:
|
||||||
cat dist/config.yaml
|
cat dist/config.yaml
|
||||||
@@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check
|
|||||||
goreleaser release --clean
|
goreleaser release --clean
|
||||||
|
|
||||||
release-snapshot: clean cli-deps docs web check
|
release-snapshot: clean cli-deps docs web check
|
||||||
goreleaser release --snapshot --skip-publish --clean
|
goreleaser release --snapshot --clean
|
||||||
|
|
||||||
release-checks:
|
release-checks:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
|
|||||||
@@ -253,3 +253,4 @@ Third-party libraries and resources:
|
|||||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||||
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
||||||
* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
|
* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications
|
||||||
|
* [Sprig](https://github.com/Masterminds/sprig) (MIT) is used to add template parsing functions
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func WithMarkdown() PublishOption {
|
|||||||
return WithHeader("X-Markdown", "yes")
|
return WithHeader("X-Markdown", "yes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTemplate instructs the server to use a specific template for the message. If templateName is is "yes" or "1",
|
||||||
|
// the server will interpret the message and title as a template.
|
||||||
|
func WithTemplate(templateName string) PublishOption {
|
||||||
|
return WithHeader("X-Template", templateName)
|
||||||
|
}
|
||||||
|
|
||||||
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||||
func WithFilename(filename string) PublishOption {
|
func WithFilename(filename string) PublishOption {
|
||||||
return WithHeader("X-Filename", filename)
|
return WithHeader("X-Filename", filename)
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
} else if u.Role == user.RoleAdmin {
|
} else if u.Role == user.RoleAdmin {
|
||||||
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||||
}
|
}
|
||||||
@@ -114,13 +116,13 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if permission.IsReadWrite() {
|
if permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.Writer, "granted read-write access to topic %s\n\n", topic)
|
||||||
} else if permission.IsRead() {
|
} else if permission.IsRead() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.Writer, "granted read-only access to topic %s\n\n", topic)
|
||||||
} else if permission.IsWrite() {
|
} else if permission.IsWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.Writer, "granted write-only access to topic %s\n\n", topic)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.Writer, "revoked all access to topic %s\n\n", topic)
|
||||||
}
|
}
|
||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
|||||||
if err := manager.ResetAccess("", ""); err != nil {
|
if err := manager.ResetAccess("", ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
fmt.Fprintln(c.App.Writer, "reset access for all users")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
|
|||||||
if err := manager.ResetAccess(username, ""); err != nil {
|
if err := manager.ResetAccess(username, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
|
fmt.Fprintf(c.App.Writer, "reset access for user %s\n\n", username)
|
||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string
|
|||||||
if err := manager.ResetAccess(username, topic); err != nil {
|
if err := manager.ResetAccess(username, topic); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
|
fmt.Fprintf(c.App.Writer, "reset access for user %s and topic %s\n\n", username, topic)
|
||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
|
|||||||
|
|
||||||
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||||
users, err := manager.User(username)
|
users, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
|||||||
if u.Tier != nil {
|
if u.Tier != nil {
|
||||||
tier = u.Tier.Name
|
tier = u.Tier.Name
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
|
provisioned := ""
|
||||||
|
if u.Provisioned {
|
||||||
|
provisioned = ", server config"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.Writer, "user %s (role: %s, tier: %s%s)\n", u.Name, u.Role, tier, provisioned)
|
||||||
if u.Role == user.RoleAdmin {
|
if u.Role == user.RoleAdmin {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
fmt.Fprintf(c.App.Writer, "- read-write access to all topics (admin role)\n")
|
||||||
} else if len(grants) > 0 {
|
} else if len(grants) > 0 {
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
if grant.Allow.IsReadWrite() {
|
grantProvisioned := ""
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
if grant.Provisioned {
|
||||||
} else if grant.Allow.IsRead() {
|
grantProvisioned = " (server config)"
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
}
|
||||||
} else if grant.Allow.IsWrite() {
|
if grant.Permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.Writer, "- read-write access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
|
} else if grant.Permission.IsRead() {
|
||||||
|
fmt.Fprintf(c.App.Writer, "- read-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
|
} else if grant.Permission.IsWrite() {
|
||||||
|
fmt.Fprintf(c.App.Writer, "- write-only access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.Writer, "- no access to topic %s%s\n", grant.TopicPattern, grantProvisioned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
fmt.Fprintf(c.App.Writer, "- no topic-specific permissions\n")
|
||||||
}
|
}
|
||||||
if u.Name == user.Everyone {
|
if u.Name == user.Everyone {
|
||||||
access := manager.DefaultAccess()
|
access := manager.DefaultAccess()
|
||||||
if access.IsReadWrite() {
|
if access.IsReadWrite() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.Writer, "- read-write access to all (other) topics (server config)")
|
||||||
} else if access.IsRead() {
|
} else if access.IsRead() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.Writer, "- read-only access to all (other) topics (server config)")
|
||||||
} else if access.IsWrite() {
|
} else if access.IsWrite() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.Writer, "- write-only access to all (other) topics (server config)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
fmt.Fprintln(c.App.Writer, "- no access to any (other) topics (server config)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ func TestCLI_Access_Show(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
require.Contains(t, stdout.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
@@ -30,7 +30,7 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
|||||||
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
|
||||||
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
expected := `user phil (role: admin, tier: none)
|
expected := `user phil (role: admin, tier: none)
|
||||||
- read-write access to all topics (admin role)
|
- read-write access to all topics (admin role)
|
||||||
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
|
|||||||
- read-only access to topic announcements
|
- read-only access to topic announcements
|
||||||
- no access to any (other) topics (server config)
|
- no access to any (other) topics (server config)
|
||||||
`
|
`
|
||||||
require.Equal(t, expected, stderr.String())
|
require.Equal(t, expected, stdout.String())
|
||||||
|
|
||||||
// See if access permissions match
|
// See if access permissions match
|
||||||
app, _, _, _ = newTestApp()
|
app, _, _, _ = newTestApp()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var flagsPublish = append(
|
|||||||
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
&cli.StringFlag{Name: "actions", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ACTIONS"}, Usage: "actions JSON array or simple definition"},
|
||||||
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||||
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
||||||
|
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
|
||||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
@@ -98,6 +99,7 @@ func execPublish(c *cli.Context) error {
|
|||||||
actions := c.String("actions")
|
actions := c.String("actions")
|
||||||
attach := c.String("attach")
|
attach := c.String("attach")
|
||||||
markdown := c.Bool("markdown")
|
markdown := c.Bool("markdown")
|
||||||
|
template := c.String("template")
|
||||||
filename := c.String("filename")
|
filename := c.String("filename")
|
||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
@@ -146,6 +148,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if markdown {
|
if markdown {
|
||||||
options = append(options, client.WithMarkdown())
|
options = append(options, client.WithMarkdown())
|
||||||
}
|
}
|
||||||
|
if template != "" {
|
||||||
|
options = append(options, client.WithTemplate(template))
|
||||||
|
}
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
options = append(options, client.WithFilename(filename))
|
options = append(options, client.WithFilename(filename))
|
||||||
}
|
}
|
||||||
|
|||||||
149
cmd/serve.go
149
cmd/serve.go
@@ -16,10 +16,10 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/server"
|
"heckel.io/ntfy/v2/server"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -29,13 +29,9 @@ func init() {
|
|||||||
commands = append(commands, cmdServe)
|
commands = append(commands, cmdServe)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
|
||||||
)
|
|
||||||
|
|
||||||
var flagsServe = append(
|
var flagsServe = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
append([]cli.Flag{}, flagsDefault...),
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, Usage: "config file"},
|
||||||
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: "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 as HTTP listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used 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 as HTTPS listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
|
||||||
@@ -52,10 +48,14 @@ var flagsServe = append(
|
|||||||
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-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-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||||
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: "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.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
|
||||||
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
|
||||||
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
|
||||||
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-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"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), 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"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "template-dir", Aliases: []string{"template_dir"}, EnvVars: []string{"NTFY_TEMPLATE_DIR"}, Value: server.DefaultTemplateDir, Usage: "directory to load named message templates from"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
|
||||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||||
@@ -157,10 +157,14 @@ func execServe(c *cli.Context) error {
|
|||||||
authFile := c.String("auth-file")
|
authFile := c.String("auth-file")
|
||||||
authStartupQueries := c.String("auth-startup-queries")
|
authStartupQueries := c.String("auth-startup-queries")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
|
authUsersRaw := c.StringSlice("auth-users")
|
||||||
|
authAccessRaw := c.StringSlice("auth-access")
|
||||||
|
authTokensRaw := c.StringSlice("auth-tokens")
|
||||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||||
|
templateDir := c.String("template-dir")
|
||||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||||
managerIntervalStr := c.String("manager-interval")
|
managerIntervalStr := c.String("manager-interval")
|
||||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||||
@@ -275,6 +279,8 @@ func execServe(c *cli.Context) error {
|
|||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
return errors.New("if set, FCM key file must exist")
|
return errors.New("if set, FCM key file must exist")
|
||||||
|
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||||
|
return errors.New("cannot set firebase-key-file, support for Firebase is not available (nofirebase)")
|
||||||
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
|
||||||
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
|
||||||
} else if keepaliveInterval < 5*time.Second {
|
} else if keepaliveInterval < 5*time.Second {
|
||||||
@@ -316,6 +322,8 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||||
} else if enableSignup && !enableLogin {
|
} else if enableSignup && !enableLogin {
|
||||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
|
} else if !payments.Available && (stripeSecretKey != "" || stripeWebhookKey != "") {
|
||||||
|
return errors.New("cannot set stripe-secret-key or stripe-webhook-key, support for payments is not available in this build (nopayments)")
|
||||||
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||||
@@ -325,6 +333,8 @@ func execServe(c *cli.Context) error {
|
|||||||
if messageSizeLimit > 5*1024*1024 {
|
if messageSizeLimit > 5*1024*1024 {
|
||||||
return errors.New("message-size-limit cannot be higher than 5M")
|
return errors.New("message-size-limit cannot be higher than 5M")
|
||||||
}
|
}
|
||||||
|
} else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
|
||||||
|
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
|
||||||
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
|
||||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||||
} else if behindProxy && proxyForwardedHeader == "" {
|
} else if behindProxy && proxyForwardedHeader == "" {
|
||||||
@@ -346,11 +356,23 @@ func execServe(c *cli.Context) error {
|
|||||||
webRoot = "/" + webRoot
|
webRoot = "/" + webRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default auth permissions
|
// Convert default auth permission, read provisioned users
|
||||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
}
|
}
|
||||||
|
authUsers, err := parseUsers(authUsersRaw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authAccess, err := parseAccess(authUsers, authAccessRaw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authTokens, err := parseTokens(authUsers, authTokensRaw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Special case: Unset default
|
// Special case: Unset default
|
||||||
if listenHTTP == "-" {
|
if listenHTTP == "-" {
|
||||||
@@ -380,8 +402,7 @@ func execServe(c *cli.Context) error {
|
|||||||
|
|
||||||
// Stripe things
|
// Stripe things
|
||||||
if stripeSecretKey != "" {
|
if stripeSecretKey != "" {
|
||||||
stripe.EnableTelemetry = false // Whoa!
|
payments.Setup(stripeSecretKey)
|
||||||
stripe.Key = stripeSecretKey
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add default forbidden topics
|
// Add default forbidden topics
|
||||||
@@ -406,10 +427,14 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AuthFile = authFile
|
conf.AuthFile = authFile
|
||||||
conf.AuthStartupQueries = authStartupQueries
|
conf.AuthStartupQueries = authStartupQueries
|
||||||
conf.AuthDefault = authDefault
|
conf.AuthDefault = authDefault
|
||||||
|
conf.AuthUsers = authUsers
|
||||||
|
conf.AuthAccess = authAccess
|
||||||
|
conf.AuthTokens = authTokens
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
|
conf.TemplateDir = templateDir
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.DisallowedTopics = disallowedTopics
|
conf.DisallowedTopics = disallowedTopics
|
||||||
@@ -518,6 +543,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseUsers(usersRaw []string) ([]*user.User, error) {
|
||||||
|
users := make([]*user.User, 0)
|
||||||
|
for _, userLine := range usersRaw {
|
||||||
|
parts := strings.Split(userLine, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid auth-users: %s, expected format: 'name:hash:role'", userLine)
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(parts[0])
|
||||||
|
passwordHash := strings.TrimSpace(parts[1])
|
||||||
|
role := user.Role(strings.TrimSpace(parts[2]))
|
||||||
|
if !user.AllowedUsername(username) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
|
||||||
|
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
|
||||||
|
} else if !user.AllowedRole(role) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
|
||||||
|
}
|
||||||
|
users = append(users, &user.User{
|
||||||
|
Name: username,
|
||||||
|
Hash: passwordHash,
|
||||||
|
Role: role,
|
||||||
|
Provisioned: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccess(users []*user.User, accessRaw []string) (map[string][]*user.Grant, error) {
|
||||||
|
access := make(map[string][]*user.Grant)
|
||||||
|
for _, accessLine := range accessRaw {
|
||||||
|
parts := strings.Split(accessLine, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, expected format: 'user:topic:permission'", accessLine)
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(parts[0])
|
||||||
|
if username == userEveryone {
|
||||||
|
username = user.Everyone
|
||||||
|
}
|
||||||
|
u, exists := util.Find(users, func(u *user.User) bool {
|
||||||
|
return u.Name == username
|
||||||
|
})
|
||||||
|
if username != user.Everyone {
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not provisioned", accessLine, username)
|
||||||
|
} else if !user.AllowedUsername(username) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, username %s invalid", accessLine, username)
|
||||||
|
} else if u.Role != user.RoleUser {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, user %s is not a regular user, only regular users can have ACL entries", accessLine, username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topic := strings.TrimSpace(parts[1])
|
||||||
|
if !user.AllowedTopicPattern(topic) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, topic pattern %s invalid", accessLine, topic)
|
||||||
|
}
|
||||||
|
permission, err := user.ParsePermission(strings.TrimSpace(parts[2]))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid auth-access: %s, permission %s invalid, %s", accessLine, parts[2], err.Error())
|
||||||
|
}
|
||||||
|
if _, exists := access[username]; !exists {
|
||||||
|
access[username] = make([]*user.Grant, 0)
|
||||||
|
}
|
||||||
|
access[username] = append(access[username], &user.Grant{
|
||||||
|
TopicPattern: topic,
|
||||||
|
Permission: permission,
|
||||||
|
Provisioned: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return access, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Token, error) {
|
||||||
|
tokens := make(map[string][]*user.Token)
|
||||||
|
for _, tokenLine := range tokensRaw {
|
||||||
|
parts := strings.Split(tokenLine, ":")
|
||||||
|
if len(parts) < 2 || len(parts) > 3 {
|
||||||
|
return nil, fmt.Errorf("invalid auth-tokens: %s, expected format: 'user:token[:label]'", tokenLine)
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(parts[0])
|
||||||
|
_, exists := util.Find(users, func(u *user.User) bool {
|
||||||
|
return u.Name == username
|
||||||
|
})
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("invalid auth-tokens: %s, user %s is not provisioned", tokenLine, username)
|
||||||
|
} else if !user.AllowedUsername(username) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-tokens: %s, username %s invalid", tokenLine, username)
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(parts[1])
|
||||||
|
if !user.ValidToken(token) {
|
||||||
|
return nil, fmt.Errorf("invalid auth-tokens: %s, token %s invalid, use 'ntfy token generate' to generate a random token", tokenLine, token)
|
||||||
|
}
|
||||||
|
var label string
|
||||||
|
if len(parts) > 2 {
|
||||||
|
label = parts[2]
|
||||||
|
}
|
||||||
|
if _, exists := tokens[username]; !exists {
|
||||||
|
tokens[username] = make([]*user.Token, 0)
|
||||||
|
}
|
||||||
|
tokens[username] = append(tokens[username], &user.Token{
|
||||||
|
Value: token,
|
||||||
|
Label: label,
|
||||||
|
Provisioned: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||||
newLevelStr, err := inputSource.String("log-level")
|
newLevelStr, err := inputSource.String("log-level")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,9 +14,461 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/client"
|
"heckel.io/ntfy/v2/client"
|
||||||
"heckel.io/ntfy/v2/test"
|
"heckel.io/ntfy/v2/test"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestParseUsers_Success(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
expected []*user.User
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single user",
|
||||||
|
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice",
|
||||||
|
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||||
|
Role: user.RoleUser,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple users with different roles",
|
||||||
|
input: []string{
|
||||||
|
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
|
||||||
|
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
|
||||||
|
},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice",
|
||||||
|
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
|
||||||
|
Role: user.RoleUser,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bob",
|
||||||
|
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
|
||||||
|
Role: user.RoleAdmin,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: []string{},
|
||||||
|
expected: []*user.User{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user with special characters in name",
|
||||||
|
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
|
||||||
|
expected: []*user.User{
|
||||||
|
{
|
||||||
|
Name: "alice.test+123@example.com",
|
||||||
|
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
|
||||||
|
Role: user.RoleUser,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseUsers(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, result, len(tt.expected))
|
||||||
|
|
||||||
|
for i, expectedUser := range tt.expected {
|
||||||
|
assert.Equal(t, expectedUser.Name, result[i].Name)
|
||||||
|
assert.Equal(t, expectedUser.Hash, result[i].Hash)
|
||||||
|
assert.Equal(t, expectedUser.Role, result[i].Role)
|
||||||
|
assert.Equal(t, expectedUser.Provisioned, result[i].Provisioned)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUsers_Errors(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid format - too few parts",
|
||||||
|
input: []string{"alice:hash"},
|
||||||
|
error: "invalid auth-users: alice:hash, expected format: 'name:hash:role'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format - too many parts",
|
||||||
|
input: []string{"alice:hash:role:extra"},
|
||||||
|
error: "invalid auth-users: alice:hash:role:extra, expected format: 'name:hash:role'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid username",
|
||||||
|
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||||
|
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid password hash - wrong prefix",
|
||||||
|
input: []string{"alice:plaintext:user"},
|
||||||
|
error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid role",
|
||||||
|
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
|
||||||
|
error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty username",
|
||||||
|
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
|
||||||
|
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseUsers(tt.input)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), tt.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAccess_Success(t *testing.T) {
|
||||||
|
users := []*user.User{
|
||||||
|
{Name: "alice", Role: user.RoleUser},
|
||||||
|
{Name: "bob", Role: user.RoleUser},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
users []*user.User
|
||||||
|
input []string
|
||||||
|
expected map[string][]*user.Grant
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single access entry",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:mytopic:read-write"},
|
||||||
|
expected: map[string][]*user.Grant{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
TopicPattern: "mytopic",
|
||||||
|
Permission: user.PermissionReadWrite,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple access entries for same user",
|
||||||
|
users: users,
|
||||||
|
input: []string{
|
||||||
|
"alice:topic1:read-only",
|
||||||
|
"alice:topic2:write-only",
|
||||||
|
},
|
||||||
|
expected: map[string][]*user.Grant{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
TopicPattern: "topic1",
|
||||||
|
Permission: user.PermissionRead,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
TopicPattern: "topic2",
|
||||||
|
Permission: user.PermissionWrite,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "access for everyone",
|
||||||
|
users: users,
|
||||||
|
input: []string{"everyone:publictopic:read-only"},
|
||||||
|
expected: map[string][]*user.Grant{
|
||||||
|
user.Everyone: {
|
||||||
|
{
|
||||||
|
TopicPattern: "publictopic",
|
||||||
|
Permission: user.PermissionRead,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wildcard topic pattern",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:topic*:read-write"},
|
||||||
|
expected: map[string][]*user.Grant{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
TopicPattern: "topic*",
|
||||||
|
Permission: user.PermissionReadWrite,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
users: users,
|
||||||
|
input: []string{},
|
||||||
|
expected: map[string][]*user.Grant{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny-all permission",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:secretopic:deny-all"},
|
||||||
|
expected: map[string][]*user.Grant{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
TopicPattern: "secretopic",
|
||||||
|
Permission: user.PermissionDenyAll,
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseAccess(tt.users, tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAccess_Errors(t *testing.T) {
|
||||||
|
users := []*user.User{
|
||||||
|
{Name: "alice", Role: user.RoleUser},
|
||||||
|
{Name: "admin", Role: user.RoleAdmin},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
users []*user.User
|
||||||
|
input []string
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid format - too few parts",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:topic"},
|
||||||
|
error: "invalid auth-access: alice:topic, expected format: 'user:topic:permission'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format - too many parts",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:topic:read:extra"},
|
||||||
|
error: "invalid auth-access: alice:topic:read:extra, expected format: 'user:topic:permission'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user not provisioned",
|
||||||
|
users: users,
|
||||||
|
input: []string{"charlie:topic:read"},
|
||||||
|
error: "invalid auth-access: charlie:topic:read, user charlie is not provisioned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "admin user cannot have ACL entries",
|
||||||
|
users: users,
|
||||||
|
input: []string{"admin:topic:read"},
|
||||||
|
error: "invalid auth-access: admin:topic:read, user admin is not a regular user, only regular users can have ACL entries",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid topic pattern",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:topic-with-invalid-chars!:read"},
|
||||||
|
error: "invalid auth-access: alice:topic-with-invalid-chars!:read, topic pattern topic-with-invalid-chars! invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid permission",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:topic:invalid-permission"},
|
||||||
|
error: "invalid auth-access: alice:topic:invalid-permission, permission invalid-permission invalid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseAccess(tt.users, tt.input)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), tt.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTokens_Success(t *testing.T) {
|
||||||
|
users := []*user.User{
|
||||||
|
{Name: "alice"},
|
||||||
|
{Name: "bob"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
users []*user.User
|
||||||
|
input []string
|
||||||
|
expected map[string][]*user.Token
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single token without label",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||||
|
expected: map[string][]*user.Token{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||||
|
Label: "",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single token with label",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:tk_abcdefghijklmnopqrstuvwxyz123:My Phone"},
|
||||||
|
expected: map[string][]*user.Token{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||||
|
Label: "My Phone",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple tokens for same user",
|
||||||
|
users: users,
|
||||||
|
input: []string{
|
||||||
|
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||||
|
"alice:tk_zyxwvutsrqponmlkjihgfedcba987:Laptop",
|
||||||
|
},
|
||||||
|
expected: map[string][]*user.Token{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||||
|
Label: "Phone",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||||
|
Label: "Laptop",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tokens for multiple users",
|
||||||
|
users: users,
|
||||||
|
input: []string{
|
||||||
|
"alice:tk_abcdefghijklmnopqrstuvwxyz123:Phone",
|
||||||
|
"bob:tk_zyxwvutsrqponmlkjihgfedcba987:Tablet",
|
||||||
|
},
|
||||||
|
expected: map[string][]*user.Token{
|
||||||
|
"alice": {
|
||||||
|
{
|
||||||
|
Value: "tk_abcdefghijklmnopqrstuvwxyz123",
|
||||||
|
Label: "Phone",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"bob": {
|
||||||
|
{
|
||||||
|
Value: "tk_zyxwvutsrqponmlkjihgfedcba987",
|
||||||
|
Label: "Tablet",
|
||||||
|
Provisioned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
users: users,
|
||||||
|
input: []string{},
|
||||||
|
expected: map[string][]*user.Token{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseTokens(tt.users, tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTokens_Errors(t *testing.T) {
|
||||||
|
users := []*user.User{
|
||||||
|
{Name: "alice"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
users []*user.User
|
||||||
|
input []string
|
||||||
|
error string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid format - too few parts",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice"},
|
||||||
|
error: "invalid auth-tokens: alice, expected format: 'user:token[:label]'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format - too many parts",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:token:label:extra:parts"},
|
||||||
|
error: "invalid auth-tokens: alice:token:label:extra:parts, expected format: 'user:token[:label]'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user not provisioned",
|
||||||
|
users: users,
|
||||||
|
input: []string{"charlie:tk_abcdefghijklmnopqrstuvwxyz123"},
|
||||||
|
error: "invalid auth-tokens: charlie:tk_abcdefghijklmnopqrstuvwxyz123, user charlie is not provisioned",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token format",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:invalid-token"},
|
||||||
|
error: "invalid auth-tokens: alice:invalid-token, token invalid-token invalid, use 'ntfy token generate' to generate a random token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token too short",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:tk_short"},
|
||||||
|
error: "invalid auth-tokens: alice:tk_short, token tk_short invalid, use 'ntfy token generate' to generate a random token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token without prefix",
|
||||||
|
users: users,
|
||||||
|
input: []string{"alice:abcdefghijklmnopqrstuvwxyz12345"},
|
||||||
|
error: "invalid auth-tokens: alice:abcdefghijklmnopqrstuvwxyz12345, token abcdefghijklmnopqrstuvwxyz12345 invalid, use 'ntfy token generate' to generate a random token",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := parseTokens(tt.users, tt.input)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Nil(t, result)
|
||||||
|
assert.Contains(t, err.Error(), tt.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||||
|
|||||||
32
cmd/tier.go
32
cmd/tier.go
@@ -182,7 +182,7 @@ func execTierAdd(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
if tier, _ := manager.Tier(code); tier != nil {
|
if tier, _ := manager.Tier(code); tier != nil {
|
||||||
if c.Bool("ignore-exists") {
|
if c.Bool("ignore-exists") {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
fmt.Fprintf(c.App.Writer, "tier %s already exists (exited successfully)\n", code)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("tier %s already exists", code)
|
return fmt.Errorf("tier %s already exists", code)
|
||||||
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
fmt.Fprintf(c.App.Writer, "tier added\n\n")
|
||||||
printTier(c, tier)
|
printTier(c, tier)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
|
|||||||
if err := manager.UpdateTier(tier); err != nil {
|
if err := manager.UpdateTier(tier); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
fmt.Fprintf(c.App.Writer, "tier updated\n\n")
|
||||||
printTier(c, tier)
|
printTier(c, tier)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
|
|||||||
if err := manager.RemoveTier(code); err != nil {
|
if err := manager.RemoveTier(code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
|||||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
fmt.Fprintf(c.App.Writer, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
fmt.Fprintf(c.App.Writer, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,21 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
||||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
require.Contains(t, stdout.String(), "tier added\n\ntier pro (id: ti_")
|
||||||
|
|
||||||
err := runTierCommand(app, conf, "add", "pro")
|
err := runTierCommand(app, conf, "add", "pro")
|
||||||
require.NotNil(t, err)
|
require.NotNil(t, err)
|
||||||
require.Equal(t, "tier pro already exists", err.Error())
|
require.Equal(t, "tier pro already exists", err.Error())
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
||||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
require.Contains(t, stdout.String(), "- Name: Pro")
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "change",
|
require.Nil(t, runTierCommand(app, conf, "change",
|
||||||
"--message-limit=999",
|
"--message-limit=999",
|
||||||
"--message-expiry-duration=2d",
|
"--message-expiry-duration=2d",
|
||||||
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
|||||||
"--stripe-yearly-price-id=price_992",
|
"--stripe-yearly-price-id=price_992",
|
||||||
"pro",
|
"pro",
|
||||||
))
|
))
|
||||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
require.Contains(t, stdout.String(), "- Message limit: 999")
|
||||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
||||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
require.Contains(t, stdout.String(), "- Email limit: 91")
|
||||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
||||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
||||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
||||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
|
||||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
require.Contains(t, stdout.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||||
require.Contains(t, stderr.String(), "tier pro removed")
|
require.Contains(t, stdout.String(), "tier pro removed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
|||||||
41
cmd/token.go
41
cmd/token.go
@@ -72,6 +72,15 @@ Example:
|
|||||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "generate",
|
||||||
|
Usage: "Generates a random token",
|
||||||
|
Action: execTokenGenerate,
|
||||||
|
Description: `Randomly generate a token to be used in provisioned tokens.
|
||||||
|
|
||||||
|
This command only generates the token value, but does not persist it anywhere.
|
||||||
|
The output can be used in the 'auth-tokens' config option.`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Description: `Manage access tokens for individual users.
|
Description: `Manage access tokens for individual users.
|
||||||
|
|
||||||
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if expires.Unix() == 0 {
|
if expires.Unix() == 0 {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
fmt.Fprintf(c.App.Writer, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
fmt.Fprintf(c.App.Writer, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
|
|||||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
fmt.Fprintf(c.App.Writer, "token %s for user %s removed\n", token, username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
|
|||||||
var users []*user.User
|
var users []*user.User
|
||||||
if username != "" {
|
if username != "" {
|
||||||
u, err := manager.User(username)
|
u, err := manager.User(username)
|
||||||
if err == user.ErrUserNotFound {
|
if errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if len(tokens) == 0 && username != "" {
|
} else if len(tokens) == 0 && username != "" {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
|
fmt.Fprintf(c.App.Writer, "user %s has no access tokens\n", username)
|
||||||
return nil
|
return nil
|
||||||
} else if len(tokens) == 0 {
|
} else if len(tokens) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
usersWithTokens++
|
usersWithTokens++
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
fmt.Fprintf(c.App.Writer, "user %s\n", u.Name)
|
||||||
for _, t := range tokens {
|
for _, t := range tokens {
|
||||||
var label, expires string
|
var label, expires, provisioned string
|
||||||
if t.Label != "" {
|
if t.Label != "" {
|
||||||
label = fmt.Sprintf(" (%s)", t.Label)
|
label = fmt.Sprintf(" (%s)", t.Label)
|
||||||
}
|
}
|
||||||
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
|
if t.Provisioned {
|
||||||
|
provisioned = " (server config)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.Writer, "- %s%s, %s, accessed from %s at %s%s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822), provisioned)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if usersWithTokens == 0 {
|
if usersWithTokens == 0 {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
fmt.Fprintf(c.App.Writer, "no users with tokens\n")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execTokenGenerate(c *cli.Context) error {
|
||||||
|
fmt.Fprintln(c.App.Writer, user.GenerateToken())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,28 +14,28 @@ func TestCLI_Token_AddListRemove(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
||||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stdout.String())
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
||||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stdout.String())
|
||||||
re := regexp.MustCompile(`tk_\w+`)
|
re := regexp.MustCompile(`tk_\w+`)
|
||||||
token := re.FindString(stderr.String())
|
token := re.FindString(stdout.String())
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
||||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stdout.String())
|
||||||
|
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||||
require.Equal(t, "no users with tokens\n", stderr.String())
|
require.Equal(t, "no users with tokens\n", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
|||||||
65
cmd/user.go
65
cmd/user.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/v2/server"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,7 +26,7 @@ func init() {
|
|||||||
|
|
||||||
var flagsUser = append(
|
var flagsUser = append(
|
||||||
append([]cli.Flag{}, flagsDefault...),
|
append([]cli.Flag{}, flagsDefault...),
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: server.DefaultConfigFile, DefaultText: server.DefaultConfigFile, Usage: "config file"},
|
||||||
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-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: "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"}),
|
||||||
)
|
)
|
||||||
@@ -94,7 +95,6 @@ Example:
|
|||||||
|
|
||||||
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
You may set the NTFY_PASSWORD environment variable to pass the new password or NTFY_PASSWORD_HASH to pass
|
||||||
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
directly the bcrypt hash. This is useful if you are updating users via scripts.
|
||||||
|
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,6 +133,22 @@ as messages per day, attachment file sizes, etc.
|
|||||||
Example:
|
Example:
|
||||||
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||||
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "hash",
|
||||||
|
Usage: "Create password hash for a predefined user",
|
||||||
|
UsageText: "ntfy user hash",
|
||||||
|
Action: execUserHash,
|
||||||
|
Description: `Asks for a password and creates a bcrypt password hash.
|
||||||
|
|
||||||
|
This command is useful to create a password hash for a user, which can then be used
|
||||||
|
for predefined users in the server config file, in auth-users.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
$ ntfy user hash
|
||||||
|
(asks for password and confirmation)
|
||||||
|
$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -195,7 +211,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
if user, _ := manager.User(username); user != nil {
|
if user, _ := manager.User(username); user != nil {
|
||||||
if c.Bool("ignore-exists") {
|
if c.Bool("ignore-exists") {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
fmt.Fprintf(c.App.Writer, "user %s already exists (exited successfully)\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("user %s already exists", username)
|
return fmt.Errorf("user %s already exists", username)
|
||||||
@@ -210,7 +226,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
if err := manager.AddUser(username, password, role, hashed); err != nil {
|
if err := manager.AddUser(username, password, role, hashed); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
fmt.Fprintf(c.App.Writer, "user %s added with role %s\n", username, role)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,13 +241,13 @@ func execUserDel(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.RemoveUser(username); err != nil {
|
if err := manager.RemoveUser(username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
@@ -263,7 +279,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
|
fmt.Fprintf(c.App.Writer, "changed password for user %s\n", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +295,26 @@ func execUserChangeRole(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.ChangeRole(username, role); err != nil {
|
if err := manager.ChangeRole(username, role); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
|
fmt.Fprintf(c.App.Writer, "changed role for user %s to %s\n", username, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func execUserHash(c *cli.Context) error {
|
||||||
|
password, err := readPasswordAndConfirm(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hash, err := user.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(c.App.Writer, hash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,19 +332,19 @@ func execUserChangeTier(c *cli.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
if _, err := manager.User(username); errors.Is(err, user.ErrUserNotFound) {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if tier == tierReset {
|
if tier == tierReset {
|
||||||
if err := manager.ResetTier(username); err != nil {
|
if err := manager.ResetTier(username); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
fmt.Fprintf(c.App.Writer, "removed tier from user %s\n", username)
|
||||||
} else {
|
} else {
|
||||||
if err := manager.ChangeTier(username, tier); err != nil {
|
if err := manager.ChangeTier(username, tier); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
fmt.Fprintf(c.App.Writer, "changed tier for user %s to %s\n", username, tier)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -345,7 +374,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
}
|
}
|
||||||
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
|
authConfig := &user.Config{
|
||||||
|
Filename: authFile,
|
||||||
|
StartupQueries: authStartupQueries,
|
||||||
|
DefaultAccess: authDefault,
|
||||||
|
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
|
||||||
|
BcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||||
|
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||||
|
}
|
||||||
|
return user.NewManager(authConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_User_Add_Exists(t *testing.T) {
|
func TestCLI_User_Add_Exists(t *testing.T) {
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
|
|
||||||
app, stdin, _, _ = newTestApp()
|
app, stdin, _, _ = newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
@@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
|
|||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role admin")
|
require.Contains(t, stdout.String(), "user phil added with role admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||||
@@ -60,19 +60,27 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
|||||||
|
|
||||||
func TestCLI_User_ChangePass(t *testing.T) {
|
func TestCLI_User_ChangePass(t *testing.T) {
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
|
conf.AuthUsers = []*user.User{
|
||||||
|
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
|
||||||
|
}
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Change pass
|
// Change pass
|
||||||
app, stdin, _, stderr = newTestApp()
|
app, stdin, stdout, _ = newTestApp()
|
||||||
stdin.WriteString("newpass\nnewpass")
|
stdin.WriteString("newpass\nnewpass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
|
||||||
require.Contains(t, stderr.String(), "changed password for user phil")
|
require.Contains(t, stdout.String(), "changed password for user phil")
|
||||||
|
|
||||||
|
// Cannot change provisioned user's pass
|
||||||
|
app, stdin, _, _ = newTestApp()
|
||||||
|
stdin.WriteString("newpass\nnewpass")
|
||||||
|
require.Error(t, runUserCommand(app, conf, "change-pass", "philuser"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_User_ChangeRole(t *testing.T) {
|
func TestCLI_User_ChangeRole(t *testing.T) {
|
||||||
@@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
|
|||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Change role
|
// Change role
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
|
||||||
require.Contains(t, stderr.String(), "changed role for user phil to admin")
|
require.Contains(t, stdout.String(), "changed role for user phil to admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_User_Delete(t *testing.T) {
|
func TestCLI_User_Delete(t *testing.T) {
|
||||||
@@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) {
|
|||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
// Add user
|
// Add user
|
||||||
app, stdin, _, stderr := newTestApp()
|
app, stdin, stdout, _ := newTestApp()
|
||||||
stdin.WriteString("mypass\nmypass")
|
stdin.WriteString("mypass\nmypass")
|
||||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
require.Contains(t, stdout.String(), "user phil added with role user")
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
app, _, _, stderr = newTestApp()
|
app, _, stdout, _ = newTestApp()
|
||||||
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
|
||||||
require.Contains(t, stderr.String(), "user phil removed")
|
require.Contains(t, stdout.String(), "user phil removed")
|
||||||
|
|
||||||
// Delete user again (does not exist)
|
// Delete user again (does not exist)
|
||||||
app, _, _, _ = newTestApp()
|
app, _, _, _ = newTestApp()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build !noserver
|
//go:build !noserver && !nowebpush
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(c.App.ErrWriter, "Web Push keys written to %s.\n", outputFile)
|
_, err = fmt.Fprintf(c.App.Writer, "Web Push keys written to %s.\n", outputFile)
|
||||||
} else {
|
} else {
|
||||||
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
|
_, err = fmt.Fprintf(c.App.Writer, `Web Push keys generated. Add the following lines to your config file:
|
||||||
|
|
||||||
web-push-public-key: %s
|
web-push-public-key: %s
|
||||||
web-push-private-key: %s
|
web-push-private-key: %s
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -9,16 +10,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
|
||||||
require.Contains(t, stderr.String(), "Web Push keys generated.")
|
require.Contains(t, stdout.String(), "Web Push keys generated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
func TestCLI_WebPush_WriteKeysToFile(t *testing.T) {
|
||||||
app, _, _, stderr := newTestApp()
|
tempDir := t.TempDir()
|
||||||
|
t.Chdir(tempDir)
|
||||||
|
app, _, stdout, _ := newTestApp()
|
||||||
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
|
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys", "--output-file=key-file.yaml"))
|
||||||
require.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
|
||||||
require.FileExists(t, "key-file.yaml")
|
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
|
|||||||
204
docs/config.md
204
docs/config.md
@@ -88,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
|||||||
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
||||||
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
||||||
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
||||||
|
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
|
||||||
NTFY_BEHIND_PROXY: true
|
NTFY_BEHIND_PROXY: true
|
||||||
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
||||||
NTFY_ENABLE_LOGIN: true
|
NTFY_ENABLE_LOGIN: true
|
||||||
@@ -188,19 +189,31 @@ ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based
|
|||||||
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
|
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
|
||||||
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
|
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
|
||||||
|
|
||||||
To set up auth, simply **configure the following two options**:
|
To set up auth, **configure the following options**:
|
||||||
|
|
||||||
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
|
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
|
||||||
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
|
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
|
||||||
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
|
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
|
||||||
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
|
set to `read-write` (default), `read-only`, `write-only` or `deny-all`. **If you are setting up a private instance,
|
||||||
|
you'll want to set this to `deny-all`** (see [private instance example](#example-private-instance)).
|
||||||
|
|
||||||
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
|
Once configured, you can use
|
||||||
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
|
|
||||||
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user
|
- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
|
||||||
accessing them has the right permissions.
|
- the `ntfy access` command and the `auth-access` option to [modify the access control list](#access-control-list-acl)
|
||||||
|
and topic patterns, and
|
||||||
|
- the `ntfy token` command and the `auth-tokens` config option to [manage access tokens](#access-tokens) for users.
|
||||||
|
|
||||||
|
These commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server,
|
||||||
|
and only if the user accessing them has the right permissions.
|
||||||
|
|
||||||
### Users and roles
|
### Users and roles
|
||||||
|
Users can be added to the ntfy user database in two different ways
|
||||||
|
|
||||||
|
* [Using the CLI](#users-via-the-cli): Using the `ntfy user` command, you can manually add/update/remove users.
|
||||||
|
* [In the config](#users-via-the-config): You can provision users in the `server.yml` file via `auth-users` key.
|
||||||
|
|
||||||
|
#### Users via the CLI
|
||||||
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
|
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||||
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
|
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
|
||||||
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
|
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
|
||||||
@@ -221,12 +234,54 @@ ntfy user del phil # Delete user phil
|
|||||||
ntfy user change-pass phil # Change password for user phil
|
ntfy user change-pass phil # Change password for user phil
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
||||||
|
ntfy user hash # Generate password hash, use with auth-users config option
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Users via the config
|
||||||
|
As an alternative to manually creating users via the `ntfy user` CLI command, you can provision users declaratively in
|
||||||
|
the `server.yml` file by adding them to the `auth-users` array. This is useful for general admins, or if you'd like to
|
||||||
|
deploy your ntfy server via Docker/Ansible without manually editing the database.
|
||||||
|
|
||||||
|
The `auth-users` option is a list of users that are automatically created/updated when the server starts. Users
|
||||||
|
previously defined in the config but later removed will be deleted. Each entry is defined in the format `<username>:<password-hash>:<role>`.
|
||||||
|
|
||||||
|
Here's an example with two users: `phil` is an admin, `ben` is a regular user.
|
||||||
|
|
||||||
|
=== "Declarative users in /etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
|
auth-users:
|
||||||
|
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||||
|
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Declarative users via env variables"
|
||||||
|
```
|
||||||
|
# Comma-separated list, use single quotes to avoid issues with the bcrypt hash
|
||||||
|
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||||
|
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||||
|
```
|
||||||
|
|
||||||
|
The password hash can be created using `ntfy user hash` or an [online bcrypt generator](https://bcrypt-generator.com/) (though
|
||||||
|
note that you're putting your password in an untrusted website).
|
||||||
|
|
||||||
|
!!! important
|
||||||
|
Users added declaratively via the config file are marked in the database as "provisioned users". Removing users
|
||||||
|
from the config file will **delete them from the database** the next time ntfy is restarted.
|
||||||
|
|
||||||
|
Also, users that were originally manually created will be "upgraded" to be provisioned users if they are added to
|
||||||
|
the config. Adding a user manually, then adding it to the config, and then removing it from the config will hence
|
||||||
|
lead to the **deletion of that user**.
|
||||||
|
|
||||||
### Access control list (ACL)
|
### Access control list (ACL)
|
||||||
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
|
||||||
Each entry represents the access permissions for a user to a specific topic or topic pattern.
|
Each entry represents the access permissions for a user to a specific topic or topic pattern. Entries can be created in
|
||||||
|
two different ways:
|
||||||
|
|
||||||
|
* [Using the CLI](#acl-entries-via-the-cli): Using the `ntfy access` command, you can manually edit the access control list.
|
||||||
|
* [In the config](#acl-entries-via-the-config): You can provision ACL entries in the `server.yml` file via `auth-access` key.
|
||||||
|
|
||||||
|
#### ACL entries via the CLI
|
||||||
The ACL can be displayed or modified with the `ntfy access` command:
|
The ACL can be displayed or modified with the `ntfy access` command:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -282,6 +337,51 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
|
|||||||
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||||
|
|
||||||
|
#### ACL entries via the config
|
||||||
|
As an alternative to manually creating ACL entries via the `ntfy access` CLI command, you can provision access control
|
||||||
|
entries declaratively in the `server.yml` file by adding them to the `auth-access` array, similar to the `auth-users`
|
||||||
|
option (see [users via the config](#users-via-the-config).
|
||||||
|
|
||||||
|
The `auth-access` option is a list of access control entries that are automatically created/updated when the server starts.
|
||||||
|
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<topic-pattern>:<access>`.
|
||||||
|
|
||||||
|
The `<username>` can be any existing, provisioned user as defined in the `auth-users` section (see [users via the config](#users-via-the-config)),
|
||||||
|
or `everyone`/`*` for anonymous access. The `<topic-pattern>` can be a specific topic name or a pattern with wildcards (`*`). The
|
||||||
|
`<access>` can be one of the following:
|
||||||
|
|
||||||
|
* `read-write` or `rw`: Allows both publishing to and subscribing to the topic
|
||||||
|
* `read-only`, `read`, or `ro`: Allows only subscribing to the topic
|
||||||
|
* `write-only`, `write`, or `wo`: Allows only publishing to the topic
|
||||||
|
* `deny-all`, `deny`, or `none`: Denies all access to the topic
|
||||||
|
|
||||||
|
Here's an example with several ACL entries:
|
||||||
|
|
||||||
|
=== "Declarative ACL entries in /etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
|
auth-users:
|
||||||
|
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||||
|
- "ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||||
|
auth-access:
|
||||||
|
- "phil:mytopic:rw"
|
||||||
|
- "ben:alerts-*:rw"
|
||||||
|
- "ben:system-logs:ro"
|
||||||
|
- "*:announcements:ro" # or: "everyone:announcements,ro"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Declarative ACL entries via env variables"
|
||||||
|
```
|
||||||
|
# Comma-separated list
|
||||||
|
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||||
|
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||||
|
NTFY_AUTH_ACCESS='phil:mytopic:rw,ben:alerts-*:rw,ben:system-logs:ro,*:announcements:ro'
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the `auth-users` section defines two users, `phil` and `ben`. The `auth-access` section defines
|
||||||
|
access control entries for these users. `phil` has read-write access to the topic `mytopic`, while `ben` has read-write
|
||||||
|
access to all topics starting with `alerts-` and read-only access to the topic `system-logs`. The last entry allows
|
||||||
|
anonymous users (i.e. clients that do not authenticate) to read the `announcements` topic.
|
||||||
|
|
||||||
### Access tokens
|
### Access tokens
|
||||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
||||||
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
||||||
@@ -292,6 +392,12 @@ want to use a dedicated token to publish from your backup host, and one from you
|
|||||||
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
||||||
but not yet implemented.
|
but not yet implemented.
|
||||||
|
|
||||||
|
You can create access tokens in two different ways:
|
||||||
|
|
||||||
|
* [Using the CLI](#tokens-via-the-cli): Using the `ntfy token` command, you can manually add/update/remove tokens.
|
||||||
|
* [In the config](#tokens-via-the-config): You can provision access tokens in the `server.yml` file via `auth-tokens` key.
|
||||||
|
|
||||||
|
#### Tokens via the CLI
|
||||||
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
||||||
automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
|
automatically (or never expire). Each user can have up to 60 tokens (hardcoded).
|
||||||
|
|
||||||
@@ -302,6 +408,7 @@ ntfy token list phil # Shows list of tokens for user phil
|
|||||||
ntfy token add phil # Create token for user phil which never expires
|
ntfy token add phil # Create token for user phil which never expires
|
||||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||||
ntfy token remove phil tk_th2sxr... # Delete token
|
ntfy token remove phil tk_th2sxr... # Delete token
|
||||||
|
ntfy token generate # Generate random token, can be used in auth-tokens config option
|
||||||
```
|
```
|
||||||
|
|
||||||
**Creating an access token:**
|
**Creating an access token:**
|
||||||
@@ -309,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token
|
|||||||
$ ntfy token add --expires=30d --label="backups" phil
|
$ ntfy token add --expires=30d --label="backups" phil
|
||||||
$ ntfy token list
|
$ ntfy token list
|
||||||
user phil
|
user phil
|
||||||
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
- tk_7eevizlsiwf9yi4uxsrs83r4352o0 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
||||||
```
|
```
|
||||||
|
|
||||||
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
||||||
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||||
|
|
||||||
### Example: Private instance
|
#### Tokens via the config
|
||||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
Access tokens can be pre-provisioned in the `server.yml` configuration file using the `auth-tokens` config option.
|
||||||
|
This is useful for automated setups, Docker environments, or when you want to define tokens declaratively.
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
The `auth-tokens` option is a list of access tokens that are automatically created/updated when the server starts.
|
||||||
|
When entries are removed, they are deleted from the database. Each entry is defined in the format `<username>:<token>[:<label>]`.
|
||||||
|
|
||||||
|
The `<username>` must be an existing, provisioned user, as defined in the `auth-users` section (see [users via the config](#users-via-the-config)).
|
||||||
|
The `<token>` is a valid access token, which must start with `tk_` and be 32 characters long (including the prefix). You can generate
|
||||||
|
random tokens using the `ntfy token generate` command. The optional `<label>` is a human-readable label for the token,
|
||||||
|
which can be used to identify it later.
|
||||||
|
|
||||||
|
Once configured, these tokens can be used to authenticate API requests just like tokens created via the CLI.
|
||||||
|
For usage examples, see [authenticate via access tokens](publish.md#access-tokens).
|
||||||
|
|
||||||
|
Here's an example:
|
||||||
|
|
||||||
|
=== "Declarative tokens in /etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
|
auth-users:
|
||||||
|
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||||
|
- "backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user"
|
||||||
|
auth-tokens:
|
||||||
|
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76"
|
||||||
|
- "backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Declarative tokens via env variables"
|
||||||
|
```
|
||||||
|
# Comma-separated list
|
||||||
|
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||||
|
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
|
||||||
|
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script'
|
||||||
|
```
|
||||||
|
|
||||||
|
In this example, the `auth-users` section defines two users, `phil` and `backup-service`. The `auth-tokens` section
|
||||||
|
defines access tokens for these users. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76`, while `backup-service`
|
||||||
|
has a token `tk_f099we8uzj7xi5qshzajwp6jffvkz` with the label "Backup script".
|
||||||
|
|
||||||
|
### Example: Private instance
|
||||||
|
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`,
|
||||||
|
and to configure users in the `auth-users` section (see [users via the config](#users-via-the-config)),
|
||||||
|
access control entries in the `auth-access` section (see [ACL entries via the config](#acl-entries-via-the-config)),
|
||||||
|
and access tokens in the `auth-tokens` section (see [access tokens via the config](#tokens-via-the-config)).
|
||||||
|
|
||||||
|
Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script`
|
||||||
|
with the password `backup-script`. The admin user has full access to all topics, while regular user can only
|
||||||
|
access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means
|
||||||
|
that all other users and anonymous access are denied by default.
|
||||||
|
|
||||||
|
=== "Config via /etc/ntfy/server.yml"
|
||||||
``` yaml
|
``` yaml
|
||||||
auth-file: "/var/lib/ntfy/user.db"
|
auth-file: "/var/lib/ntfy/user.db"
|
||||||
auth-default-access: "deny-all"
|
auth-default-access: "deny-all"
|
||||||
|
auth-users:
|
||||||
|
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||||
|
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
|
||||||
|
auth-access:
|
||||||
|
- "backup-service:backups:rw"
|
||||||
|
auth-tokens:
|
||||||
|
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token"
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, simply create an `admin` user:
|
=== "Config via env variables"
|
||||||
|
``` yaml
|
||||||
```
|
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||||
$ ntfy user add --role=admin phil
|
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
||||||
password: mypass
|
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
|
||||||
confirm: mypass
|
NTFY_AUTH_ACCESS='backup-service:backups:rw'
|
||||||
user phil added with role admin
|
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
|
||||||
```
|
```
|
||||||
|
|
||||||
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||||
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
|
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password.
|
||||||
|
|
||||||
|
Here's a simple example (using the credentials of the `phil` user):
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_amd64.tar.gz
|
tar zxvf ntfy_2.14.0_linux_amd64.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.14.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_armv6.tar.gz
|
tar zxvf ntfy_2.14.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.14.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_armv7.tar.gz
|
tar zxvf ntfy_2.14.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.14.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_linux_arm64.tar.gz
|
tar zxvf ntfy_2.14.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.14.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.14.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -118,7 +118,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -126,7 +126,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -134,7 +134,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -144,28 +144,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -195,18 +195,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_darwin_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
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
|
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).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_darwin_all.tar.gz > ntfy_2.12.0_darwin_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_darwin_all.tar.gz > ntfy_2.14.0_darwin_all.tar.gz
|
||||||
tar zxvf ntfy_2.12.0_darwin_all.tar.gz
|
tar zxvf ntfy_2.14.0_darwin_all.tar.gz
|
||||||
sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.14.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_2.12.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_2.14.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ brew install ntfy
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
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/v2.12.0/ntfy_2.12.0_windows_amd64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.14.0/ntfy_2.14.0_windows_amd64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
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).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
- [Ntfy_CSV_Reminders](https://github.com/thiswillbeyourgithub/Ntfy_CSV_Reminders) - A Python tool that sends random-timing phone notifications for recurring tasks by using daily probability checks based on CSV-defined frequencies.
|
||||||
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
- [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in.
|
||||||
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
- [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails
|
||||||
|
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
||||||
|
|
||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
@@ -175,6 +176,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
|||||||
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
|
||||||
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
|
||||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||||
|
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
|
||||||
|
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
|||||||
210
docs/publish.md
210
docs/publish.md
File diff suppressed because one or more lines are too long
1507
docs/publish/template-functions.md
Normal file
1507
docs/publish/template-functions.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,41 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
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).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
|
### ntfy server v2.14.0
|
||||||
|
Released August 5, 2025
|
||||||
|
|
||||||
|
This release adds support for [declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config). This allows you to define users, ACL entries and tokens in the config file, which is useful for static deployments or deployments that use a configuration management system.
|
||||||
|
|
||||||
|
It also adds support for [pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support, as well as advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) functions.
|
||||||
|
|
||||||
|
❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier), [Liberapay](https://en.liberapay.com/ntfy/), Bitcoin (`1626wjrw3uWk9adyjCfYwafw4sQWujyjn8`), or by buying a [paid plan via the web app](https://ntfy.sh/app). ntfy
|
||||||
|
will always remain open source.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* [Declarative users](config.md#users-via-the-config), [declarative ACL entries](config.md#acl-entries-via-the-config) and [declarative tokens](config.md#tokens-via-the-config) ([#464](https://github.com/binwiederhier/ntfy/issues/464), [#1384](https://github.com/binwiederhier/ntfy/pull/1384), [#1413](https://github.com/binwiederhier/ntfy/pull/1413), thanks to [pinpox](https://github.com/pinpox) for reporting, to [@wunter8](https://github.com/wunter8) for reviewing and implementing parts of it)
|
||||||
|
* [Pre-defined templates](publish.md#pre-defined-templates) and [custom templates](publish.md#custom-templates) for enhanced JSON webhook support ([#1390](https://github.com/binwiederhier/ntfy/pull/1390))
|
||||||
|
* Support of advanced [template functions](publish.md#template-functions) based on the [Sprig](https://github.com/Masterminds/sprig) library ([#1121](https://github.com/binwiederhier/ntfy/issues/1121), thanks to [@davidatkinsondoyle](https://github.com/davidatkinsondoyle) for reporting, to [@wunter8](https://github.com/wunter8) for implementing, and to the Sprig team for their work)
|
||||||
|
|
||||||
|
### ntfy server v2.13.0
|
||||||
|
Released July 10, 2025
|
||||||
|
|
||||||
|
This is a relatively small release, mainly to support IPv6 and to add more sophisticated
|
||||||
|
proxy header support. Quick reminder that if you like ntfy, **please consider sponsoring us**
|
||||||
|
via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app).
|
||||||
|
ntfy will always remain open source.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
|
||||||
|
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
|
||||||
|
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
|
||||||
|
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
|
||||||
|
|
||||||
|
**Languages**
|
||||||
|
|
||||||
|
* Update new languages from Weblate. Thanks to all the contributors!
|
||||||
|
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
||||||
|
|
||||||
### ntfy server v2.12.0
|
### ntfy server v2.12.0
|
||||||
Released May 29, 2025
|
Released May 29, 2025
|
||||||
|
|
||||||
@@ -1433,18 +1468,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy server v2.13.0 (UNRELEASED)
|
### ntfy server v2.15.0 (UNRELEASED)
|
||||||
|
|
||||||
**Features:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
* Full [IPv6 support](config.md#ipv6-support) for ntfy and the official ntfy.sh server ([#519](https://github.com/binwiederhier/ntfy/issues/519)/[#1380](https://github.com/binwiederhier/ntfy/pull/1380)/[ansible#4](https://github.com/binwiederhier/ntfy-ansible/pull/4))
|
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
|
||||||
* Support `X-Client-IP`, `X-Real-IP`, `Forwarded` headers for [rate limiting](config.md#ip-based-rate-limiting) via `proxy-forwarded-header` and `proxy-trusted-hosts` ([#1360](https://github.com/binwiederhier/ntfy/pull/1360)/[#1252](https://github.com/binwiederhier/ntfy/pull/1252), thanks to [@pixitha](https://github.com/pixitha))
|
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||||
* Add STDIN support for `ntfy publish` ([#1382](https://github.com/binwiederhier/ntfy/pull/1382), thanks to [@srevn](https://github.com/srevn))
|
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
|
||||||
|
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
|
||||||
**Languages**
|
|
||||||
|
|
||||||
* Update new languages from Weblate. Thanks to all the contributors!
|
|
||||||
* Added Estonian (Esti), Galician (Galego), Romanian (Română), Slovak (Slovenčina) as new languages to the web app
|
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-template-custom.png
vendored
Normal file
BIN
docs/static/img/android-screenshot-template-custom.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/static/img/android-screenshot-template-predefined.png
vendored
Normal file
BIN
docs/static/img/android-screenshot-template-predefined.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/screenshot-github-webhook-config.png
vendored
Normal file
BIN
docs/static/img/screenshot-github-webhook-config.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -156,7 +156,7 @@ environment variables. Here are a few examples:
|
|||||||
```
|
```
|
||||||
ntfy sub mytopic 'notify-send "$m"'
|
ntfy sub mytopic 'notify-send "$m"'
|
||||||
ntfy sub topic1 /my/script.sh
|
ntfy sub topic1 /my/script.sh
|
||||||
ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p'
|
ntfy sub topic1 'echo "Message $m was received. Its title was $t and it had priority $p"'
|
||||||
```
|
```
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
|
|||||||
40
go.mod
40
go.mod
@@ -6,22 +6,22 @@ toolchain go1.24.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||||
cloud.google.com/go/storage v1.55.0 // indirect
|
cloud.google.com/go/storage v1.56.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/emersion/go-smtp v0.18.0
|
github.com/emersion/go-smtp v0.18.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9
|
github.com/gabriel-vasile/mimetype v1.4.9
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/mattn/go-sqlite3 v1.14.28
|
github.com/mattn/go-sqlite3 v1.14.30
|
||||||
github.com/olebedev/when v1.1.0
|
github.com/olebedev/when v1.1.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v2 v2.27.7
|
github.com/urfave/cli/v2 v2.27.7
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.40.0
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sync v0.15.0
|
golang.org/x/sync v0.16.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.33.0
|
||||||
golang.org/x/time v0.12.0
|
golang.org/x/time v0.12.0
|
||||||
google.golang.org/api v0.240.0
|
google.golang.org/api v0.244.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,17 +30,18 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
|
|||||||
require github.com/pkg/errors v0.9.1 // indirect
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.16.1
|
firebase.google.com/go/v4 v4.18.0
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.23.0
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
|
golang.org/x/text v0.27.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cel.dev/expr v0.24.0 // indirect
|
cel.dev/expr v0.24.0 // indirect
|
||||||
cloud.google.com/go v0.121.3 // indirect
|
cloud.google.com/go v0.121.4 // indirect
|
||||||
cloud.google.com/go/auth v0.16.2 // indirect
|
cloud.google.com/go/auth v0.16.3 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
cloud.google.com/go/compute/metadata v0.7.0 // indirect
|
||||||
cloud.google.com/go/iam v1.5.2 // indirect
|
cloud.google.com/go/iam v1.5.2 // indirect
|
||||||
@@ -60,16 +61,16 @@ require (
|
|||||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
github.com/google/s2a-go v0.1.9 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||||
@@ -91,14 +92,13 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
|
||||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||||
google.golang.org/grpc v1.73.0 // indirect
|
google.golang.org/grpc v1.74.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
80
go.sum
80
go.sum
@@ -1,9 +1,9 @@
|
|||||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||||
cloud.google.com/go v0.121.3 h1:84RD+hQXNdY5Sw/MWVAx5O9Aui/rd5VQ9HEcdN19afo=
|
cloud.google.com/go v0.121.4 h1:cVvUiY0sX0xwyxPwdSU2KsF9knOVmtRyAMt8xou0iTs=
|
||||||
cloud.google.com/go v0.121.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
|
cloud.google.com/go v0.121.4/go.mod h1:XEBchUiHFJbz4lKBZwYBDHV/rSyfFktk737TLDU089s=
|
||||||
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
|
||||||
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||||
@@ -18,12 +18,12 @@ cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFs
|
|||||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||||
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
|
||||||
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||||
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
|
||||||
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
|
||||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||||
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
|
||||||
firebase.google.com/go/v4 v4.16.1 h1:Kl5cgXmM0VOWDGT1UAx6b0T2UFWa14ak0CvYqeI7Py4=
|
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||||
firebase.google.com/go/v4 v4.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
@@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
|
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -81,8 +81,8 @@ github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
|
|||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
@@ -112,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
@@ -127,8 +127,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||||
@@ -184,8 +184,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -200,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -211,8 +211,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -225,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -236,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -249,8 +249,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -261,18 +261,18 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
|
|||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
|
google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
|
||||||
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
|
||||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b h1:eZTgydvqZO44zyTZAvMaSyAxccZZdraiSAGvqOczVvk=
|
||||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
google.golang.org/genproto v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:suyz2QBHQKlGIF92HEEsCfO1SwxXdk7PFLz+Zd9Uah4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b h1:ULiyYQ0FdsJhwwZUwbaXpZF5yUE3h+RA+gxvBu37ucc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ nav:
|
|||||||
- "Integrations + projects": integrations.md
|
- "Integrations + projects": integrations.md
|
||||||
- "Release notes": releases.md
|
- "Release notes": releases.md
|
||||||
- "Emojis 🥳 🎉": emojis.md
|
- "Emojis 🥳 🎉": emojis.md
|
||||||
|
- "Template functions": publish/template-functions.md
|
||||||
- "Troubleshooting": troubleshooting.md
|
- "Troubleshooting": troubleshooting.md
|
||||||
- "Known issues": known-issues.md
|
- "Known issues": known-issues.md
|
||||||
- "Deprecation notices": deprecations.md
|
- "Deprecation notices": deprecations.md
|
||||||
|
|||||||
21
payments/payments.go
Normal file
21
payments/payments.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//go:build !nopayments
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
import "github.com/stripe/stripe-go/v74"
|
||||||
|
|
||||||
|
// Available is a constant used to indicate that Stripe support is available.
|
||||||
|
// It can be disabled with the 'nopayments' build tag.
|
||||||
|
const Available = true
|
||||||
|
|
||||||
|
// SubscriptionStatus is an alias for stripe.SubscriptionStatus
|
||||||
|
type SubscriptionStatus stripe.SubscriptionStatus
|
||||||
|
|
||||||
|
// PriceRecurringInterval is an alias for stripe.PriceRecurringInterval
|
||||||
|
type PriceRecurringInterval stripe.PriceRecurringInterval
|
||||||
|
|
||||||
|
// Setup sets the Stripe secret key and disables telemetry
|
||||||
|
func Setup(stripeSecretKey string) {
|
||||||
|
stripe.EnableTelemetry = false // Whoa!
|
||||||
|
stripe.Key = stripeSecretKey
|
||||||
|
}
|
||||||
18
payments/payments_dummy.go
Normal file
18
payments/payments_dummy.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build nopayments
|
||||||
|
|
||||||
|
package payments
|
||||||
|
|
||||||
|
// Available is a constant used to indicate that Stripe support is available.
|
||||||
|
// It can be disabled with the 'nopayments' build tag.
|
||||||
|
const Available = false
|
||||||
|
|
||||||
|
// SubscriptionStatus is a dummy type
|
||||||
|
type SubscriptionStatus string
|
||||||
|
|
||||||
|
// PriceRecurringInterval is dummy type
|
||||||
|
type PriceRecurringInterval string
|
||||||
|
|
||||||
|
// Setup is a dummy type
|
||||||
|
func Setup(stripeSecretKey string) {
|
||||||
|
// Nothing to see here
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
// Defines default config settings (excluding limits, see below)
|
// Defines default config settings (excluding limits, see below)
|
||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
|
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||||
|
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
DefaultCacheBatchTimeout = time.Duration(0)
|
DefaultCacheBatchTimeout = time.Duration(0)
|
||||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||||
@@ -93,12 +95,16 @@ type Config struct {
|
|||||||
AuthFile string
|
AuthFile string
|
||||||
AuthStartupQueries string
|
AuthStartupQueries string
|
||||||
AuthDefault user.Permission
|
AuthDefault user.Permission
|
||||||
|
AuthUsers []*user.User
|
||||||
|
AuthAccess map[string][]*user.Grant
|
||||||
|
AuthTokens map[string][]*user.Token
|
||||||
AuthBcryptCost int
|
AuthBcryptCost int
|
||||||
AuthStatsQueueWriterInterval time.Duration
|
AuthStatsQueueWriterInterval time.Duration
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
|
TemplateDir string // Directory to load named templates from
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
DisallowedTopics []string
|
DisallowedTopics []string
|
||||||
@@ -172,7 +178,7 @@ type Config struct {
|
|||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
File: "", // Only used for testing
|
File: DefaultConfigFile, // Only used for testing
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
@@ -195,6 +201,7 @@ func NewConfig() *Config {
|
|||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
|
TemplateDir: DefaultTemplateDir,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
DisallowedTopics: DefaultDisallowedTopics,
|
DisallowedTopics: DefaultDisallowedTopics,
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ var (
|
|||||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||||
|
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
@@ -130,6 +132,8 @@ var (
|
|||||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
||||||
|
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||||
|
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||||
@@ -105,7 +107,7 @@ const (
|
|||||||
WHERE topic = ? AND published = 1
|
WHERE topic = ? AND published = 1
|
||||||
ORDER BY time DESC, id DESC
|
ORDER BY time DESC, id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
@@ -282,10 +284,17 @@ type messageCache struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
queue *util.BatchingQueue[*message]
|
queue *util.BatchingQueue[*message]
|
||||||
nop bool
|
nop bool
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
||||||
|
// Check the parent directory of the database file (makes for friendly error messages)
|
||||||
|
parentDir := filepath.Dir(filename)
|
||||||
|
if !util.FileExists(parentDir) {
|
||||||
|
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
||||||
|
}
|
||||||
|
// Open database
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -340,6 +349,8 @@ func (c *messageCache) AddMessage(m *message) error {
|
|||||||
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
|
||||||
// SQLite's busy_timeout is exceeded before erroring out.
|
// SQLite's busy_timeout is exceeded before erroring out.
|
||||||
func (c *messageCache) addMessages(ms []*message) error {
|
func (c *messageCache) addMessages(ms []*message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
if c.nop {
|
if c.nop {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -521,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkPublished(m *message) error {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -566,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -580,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -614,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
tx, err := c.db.Begin()
|
tx, err := c.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -759,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) UpdateStats(messages int64) error {
|
func (c *messageCache) UpdateStats(messages int64) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
_, err := c.db.Exec(updateStatsQuery, messages)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package server
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -90,6 +92,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
|||||||
require.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_MessagesLock(t *testing.T) {
|
||||||
|
testCacheMessagesLock(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_MessagesLock(t *testing.T) {
|
||||||
|
testCacheMessagesLock(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheMessagesLock(t *testing.T, c *messageCache) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 5000; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||||
}
|
}
|
||||||
|
|||||||
158
server/server.go
158
server/server.go
@@ -9,6 +9,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -34,6 +36,7 @@ import (
|
|||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
|
"heckel.io/ntfy/v2/util/sprig"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the main server, providing the UI and API for ntfy
|
// Server is the main server, providing the UI and API for ntfy
|
||||||
@@ -120,6 +123,15 @@ var (
|
|||||||
//go:embed docs
|
//go:embed docs
|
||||||
docsStaticFs embed.FS
|
docsStaticFs embed.FS
|
||||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||||
|
templatesDir = "templates"
|
||||||
|
|
||||||
|
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
||||||
|
// are not useful, and seem potentially troublesome.
|
||||||
|
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
||||||
|
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -129,17 +141,13 @@ const (
|
|||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
newMessageBody = "New message" // Used in poll requests as generic message
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||||
jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
|
jsonBodyBytesLimit = 131072 // Max number of bytes for a request bodys (unless MessageLimit is higher)
|
||||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||||
templateMaxExecutionTime = 100 * time.Millisecond
|
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
|
||||||
)
|
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||||
|
templateFileExtension = ".yml" // Template files must end with this extension
|
||||||
var (
|
|
||||||
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
|
||||||
// are not useful, and seem potentially troublesome.
|
|
||||||
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -158,7 +166,7 @@ func New(conf *Config) (*Server, error) {
|
|||||||
mailer = &smtpSender{config: conf}
|
mailer = &smtpSender{config: conf}
|
||||||
}
|
}
|
||||||
var stripe stripeAPI
|
var stripe stripeAPI
|
||||||
if conf.StripeSecretKey != "" {
|
if payments.Available && conf.StripeSecretKey != "" {
|
||||||
stripe = newStripeAPI()
|
stripe = newStripeAPI()
|
||||||
}
|
}
|
||||||
messageCache, err := createMessageCache(conf)
|
messageCache, err := createMessageCache(conf)
|
||||||
@@ -189,7 +197,18 @@ func New(conf *Config) (*Server, error) {
|
|||||||
}
|
}
|
||||||
var userManager *user.Manager
|
var userManager *user.Manager
|
||||||
if conf.AuthFile != "" {
|
if conf.AuthFile != "" {
|
||||||
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault, conf.AuthBcryptCost, conf.AuthStatsQueueWriterInterval)
|
authConfig := &user.Config{
|
||||||
|
Filename: conf.AuthFile,
|
||||||
|
StartupQueries: conf.AuthStartupQueries,
|
||||||
|
DefaultAccess: conf.AuthDefault,
|
||||||
|
ProvisionEnabled: true, // Enable provisioning of users and access
|
||||||
|
Users: conf.AuthUsers,
|
||||||
|
Access: conf.AuthAccess,
|
||||||
|
Tokens: conf.AuthTokens,
|
||||||
|
BcryptCost: conf.AuthBcryptCost,
|
||||||
|
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
|
||||||
|
}
|
||||||
|
userManager, err = user.NewManager(authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -936,7 +955,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
|
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
|
||||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||||
m.Title = readParam(r, "x-title", "title", "t")
|
m.Title = readParam(r, "x-title", "title", "t")
|
||||||
@@ -952,7 +971,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if attach != "" {
|
if attach != "" {
|
||||||
if !urlRegex.MatchString(attach) {
|
if !urlRegex.MatchString(attach) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||||
}
|
}
|
||||||
m.Attachment.URL = attach
|
m.Attachment.URL = attach
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -970,48 +989,53 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
}
|
}
|
||||||
if icon != "" {
|
if icon != "" {
|
||||||
if !urlRegex.MatchString(icon) {
|
if !urlRegex.MatchString(icon) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||||
}
|
}
|
||||||
m.Icon = icon
|
m.Icon = icon
|
||||||
}
|
}
|
||||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||||
if s.smtpSender == nil && email != "" {
|
if s.smtpSender == nil && email != "" {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||||
}
|
}
|
||||||
call = readParam(r, "x-call", "call")
|
call = readParam(r, "x-call", "call")
|
||||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
|
return false, false, "", "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||||
|
}
|
||||||
|
template = templateMode(readParam(r, "x-template", "template", "tpl"))
|
||||||
|
messageStr := readParam(r, "x-message", "message", "m")
|
||||||
|
if !template.InlineMode() {
|
||||||
|
// Convert "\n" to literal newline everything but inline mode
|
||||||
|
messageStr = strings.ReplaceAll(messageStr, "\\n", "\n")
|
||||||
}
|
}
|
||||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
|
||||||
if messageStr != "" {
|
if messageStr != "" {
|
||||||
m.Message = messageStr
|
m.Message = messageStr
|
||||||
}
|
}
|
||||||
var e error
|
var e error
|
||||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||||
}
|
}
|
||||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||||
if delayStr != "" {
|
if delayStr != "" {
|
||||||
if !cache {
|
if !cache {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||||
}
|
}
|
||||||
if email != "" {
|
if email != "" {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||||
}
|
}
|
||||||
if call != "" {
|
if call != "" {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||||
}
|
}
|
||||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
|
return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse
|
||||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
|
return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge
|
||||||
}
|
}
|
||||||
m.Time = delay.Unix()
|
m.Time = delay.Unix()
|
||||||
}
|
}
|
||||||
@@ -1019,14 +1043,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
if actionsStr != "" {
|
if actionsStr != "" {
|
||||||
m.Actions, e = parseActions(actionsStr)
|
m.Actions, e = parseActions(actionsStr)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap("%s", e.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||||
m.ContentType = "text/markdown"
|
m.ContentType = "text/markdown"
|
||||||
}
|
}
|
||||||
template = readBoolParam(r, false, "x-template", "template", "tpl")
|
|
||||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||||
contentEncoding := readParam(r, "content-encoding")
|
contentEncoding := readParam(r, "content-encoding")
|
||||||
if unifiedpush || contentEncoding == "aes128gcm" {
|
if unifiedpush || contentEncoding == "aes128gcm" {
|
||||||
@@ -1058,7 +1081,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
|||||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
|
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template templateMode, unifiedpush bool) error {
|
||||||
if m.Event == pollRequestEvent { // Case 1
|
if m.Event == pollRequestEvent { // Case 1
|
||||||
return s.handleBodyDiscard(body)
|
return s.handleBodyDiscard(body)
|
||||||
} else if unifiedpush {
|
} else if unifiedpush {
|
||||||
@@ -1067,8 +1090,8 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
|||||||
return s.handleBodyAsTextMessage(m, body) // Case 3
|
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
} else if template {
|
} else if template.Enabled() {
|
||||||
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1127,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateMode, body *util.PeekedReadCloser) error {
|
||||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1112,19 +1135,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
|
|||||||
return errHTTPEntityTooLargeJSONBody
|
return errHTTPEntityTooLargeJSONBody
|
||||||
}
|
}
|
||||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
if template.FileMode() {
|
||||||
return err
|
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.renderTemplateFromParams(m, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(m.Message) > s.config.MessageSizeLimit {
|
|
||||||
return errHTTPBadRequestTemplateMessageTooLarge
|
return errHTTPBadRequestTemplateMessageTooLarge
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTemplate(tpl string, source string) (string, error) {
|
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
|
||||||
|
// The template file must be in the templates directory, or in the configured template directory.
|
||||||
|
func (s *Server) renderTemplateFromFile(m *message, templateName, peekedBody string) error {
|
||||||
|
if !templateNameRegex.MatchString(templateName) {
|
||||||
|
return errHTTPBadRequestTemplateFileNotFound
|
||||||
|
}
|
||||||
|
templateContent, _ := templatesFs.ReadFile(filepath.Join(templatesDir, templateName+templateFileExtension)) // Read from the embedded filesystem first
|
||||||
|
if s.config.TemplateDir != "" {
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(s.config.TemplateDir, templateName+templateFileExtension)); len(b) > 0 {
|
||||||
|
templateContent = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(templateContent) == 0 {
|
||||||
|
return errHTTPBadRequestTemplateFileNotFound
|
||||||
|
}
|
||||||
|
var tpl templateFile
|
||||||
|
if err := yaml.Unmarshal(templateContent, &tpl); err != nil {
|
||||||
|
return errHTTPBadRequestTemplateFileInvalid
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if tpl.Message != nil {
|
||||||
|
if m.Message, err = s.renderTemplate(*tpl.Message, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tpl.Title != nil {
|
||||||
|
if m.Title, err = s.renderTemplate(*tpl.Title, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||||
|
// message and title parameters.
|
||||||
|
func (s *Server) renderTemplateFromParams(m *message, peekedBody string) error {
|
||||||
|
var err error
|
||||||
|
if m.Message, err = s.renderTemplate(m.Message, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m.Title, err = s.renderTemplate(m.Title, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTemplate renders a template with the given JSON source data.
|
||||||
|
func (s *Server) renderTemplate(tpl string, source string) (string, error) {
|
||||||
if templateDisallowedRegex.MatchString(tpl) {
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
}
|
}
|
||||||
@@ -1132,15 +1205,16 @@ func replaceTemplate(tpl string, source string) (string, error) {
|
|||||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
}
|
}
|
||||||
t, err := template.New("").Parse(tpl)
|
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errHTTPBadRequestTemplateInvalid
|
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||||
return "", errHTTPBadRequestTemplateExecuteFailed
|
if err := t.Execute(limitWriter, data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||||
|
|||||||
@@ -82,6 +82,14 @@
|
|||||||
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||||
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
||||||
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
||||||
|
# - auth-users is a list of users that are automatically created when the server starts.
|
||||||
|
# Each entry is in the format "<username>:<password-hash>:<role>", e.g. "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:user"
|
||||||
|
# Use 'ntfy user hash' to generate the password hash from a password.
|
||||||
|
# - auth-access is a list of access control entries that are automatically created when the server starts.
|
||||||
|
# Each entry is in the format "<username>:<topic-pattern>:<access>", e.g. "phil:mytopic:rw" or "phil:phil-*:rw".
|
||||||
|
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
|
||||||
|
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
|
||||||
|
# Use 'ntfy token generate' to generate a new access token.
|
||||||
#
|
#
|
||||||
# Debian/RPM package users:
|
# Debian/RPM package users:
|
||||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||||
@@ -94,6 +102,9 @@
|
|||||||
# auth-file: <filename>
|
# auth-file: <filename>
|
||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
# auth-startup-queries:
|
# auth-startup-queries:
|
||||||
|
# auth-users:
|
||||||
|
# auth-access:
|
||||||
|
# auth-tokens:
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||||
# the visitor IP address instead of the remote address of the connection.
|
# the visitor IP address instead of the remote address of the connection.
|
||||||
@@ -126,6 +137,26 @@
|
|||||||
# attachment-file-size-limit: "15M"
|
# attachment-file-size-limit: "15M"
|
||||||
# attachment-expiry-duration: "3h"
|
# attachment-expiry-duration: "3h"
|
||||||
|
|
||||||
|
# Template directory for message templates.
|
||||||
|
#
|
||||||
|
# When "X-Template: <name>" (aliases: "Template: <name>", "Tpl: <name>") or "?template=<name>" is set, transform the message
|
||||||
|
# based on one of the built-in pre-defined templates, or on a template defined in the "template-dir" directory.
|
||||||
|
#
|
||||||
|
# Template files must have the ".yml" extension and must be formatted as YAML. They may contain "title" and "message" keys,
|
||||||
|
# which are interpreted as Go templates.
|
||||||
|
#
|
||||||
|
# Example template file (e.g. /etc/ntfy/templates/grafana.yml):
|
||||||
|
# title: |
|
||||||
|
# {{- if eq .status "firing" }}
|
||||||
|
# {{ .title | default "Alert firing" }}
|
||||||
|
# {{- else if eq .status "resolved" }}
|
||||||
|
# {{ .title | default "Alert resolved" }}
|
||||||
|
# {{- end }}
|
||||||
|
# message: |
|
||||||
|
# {{ .message | trunc 2000 }}
|
||||||
|
#
|
||||||
|
# template-dir: "/etc/ntfy/templates"
|
||||||
|
|
||||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
|||||||
response.Username = u.Name
|
response.Username = u.Name
|
||||||
response.Role = string(u.Role)
|
response.Role = string(u.Role)
|
||||||
response.SyncTopic = u.SyncTopic
|
response.SyncTopic = u.SyncTopic
|
||||||
|
response.Provisioned = u.Provisioned
|
||||||
if u.Prefs != nil {
|
if u.Prefs != nil {
|
||||||
if u.Prefs.Language != nil {
|
if u.Prefs.Language != nil {
|
||||||
response.Language = *u.Prefs.Language
|
response.Language = *u.Prefs.Language
|
||||||
@@ -139,11 +140,12 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
|||||||
lastOrigin = t.LastOrigin.String()
|
lastOrigin = t.LastOrigin.String()
|
||||||
}
|
}
|
||||||
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
||||||
Token: t.Value,
|
Token: t.Value,
|
||||||
Label: t.Label,
|
Label: t.Label,
|
||||||
LastAccess: t.LastAccess.Unix(),
|
LastAccess: t.LastAccess.Unix(),
|
||||||
LastOrigin: lastOrigin,
|
LastOrigin: lastOrigin,
|
||||||
Expires: t.Expires.Unix(),
|
Expires: t.Expires.Unix(),
|
||||||
|
Provisioned: t.Provisioned,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +176,12 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
|
|||||||
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||||
}
|
}
|
||||||
|
if err := s.userManager.CanChangeUser(u.Name); err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||||
|
return errHTTPConflictProvisionedUserChange
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
if s.webPush != nil && u.ID != "" {
|
if s.webPush != nil && u.ID != "" {
|
||||||
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
||||||
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
|
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
|
||||||
@@ -208,6 +216,9 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
|
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
|
||||||
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||||
|
return errHTTPConflictProvisionedUserChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
|||||||
"token_expires": expires,
|
"token_expires": expires,
|
||||||
}).
|
}).
|
||||||
Debug("Creating token for user %s", u.Name)
|
Debug("Creating token for user %s", u.Name)
|
||||||
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -274,6 +285,9 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request
|
|||||||
Debug("Updating token for user %s as deleted", u.Name)
|
Debug("Updating token for user %s as deleted", u.Name)
|
||||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||||
|
return errHTTPConflictProvisionedTokenChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
@@ -296,6 +310,9 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
||||||
|
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||||
|
return errHTTPConflictProvisionedTokenChange
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logvr(v, r).
|
logvr(v, r).
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
|||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
u, _ := s.userManager.User("phil")
|
u, _ := s.userManager.User("phil")
|
||||||
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
|
||||||
|
|
||||||
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ChangePassword(t *testing.T) {
|
func TestAccount_ChangePassword(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.AuthUsers = []*user.User{
|
||||||
|
{Name: "philuser", Hash: "$2a$10$U4WSIYY6evyGmZaraavM2e2JeVG6EMGUKN1uUwufUeeRd4Jpg6cGC", Role: user.RoleUser}, // philuser:philpass
|
||||||
|
}
|
||||||
|
s := newTestServer(t, conf)
|
||||||
defer s.closeDatabases()
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||||
@@ -281,6 +285,12 @@ func TestAccount_ChangePassword(t *testing.T) {
|
|||||||
"Authorization": util.BasicAuth("phil", "new password"),
|
"Authorization": util.BasicAuth("phil", "new password"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Cannot change password of provisioned user
|
||||||
|
rr = request(t, s, "POST", "/v1/account/password", `{"password": "philpass", "new_password": "new password"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("philuser", "philpass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 409, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
for i, g := range grants[u.ID] {
|
for i, g := range grants[u.ID] {
|
||||||
userGrants[i] = &apiUserGrantResponse{
|
userGrants[i] = &apiUserGrantResponse{
|
||||||
Topic: g.TopicPattern,
|
Topic: g.TopicPattern,
|
||||||
Permission: g.Allow.String(),
|
Permission: g.Permission.String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
usersResponse[i] = &apiUserResponse{
|
usersResponse[i] = &apiUserResponse{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nofirebase
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,6 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
||||||
|
// It can be disabled with the 'nofirebase' build tag.
|
||||||
|
FirebaseAvailable = true
|
||||||
|
|
||||||
fcmMessageLimit = 4000
|
fcmMessageLimit = 4000
|
||||||
fcmApnsBodyMessageLimit = 100
|
fcmApnsBodyMessageLimit = 100
|
||||||
)
|
)
|
||||||
@@ -73,7 +79,7 @@ type firebaseSenderImpl struct {
|
|||||||
client *messaging.Client
|
client *messaging.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFirebaseSender(credentialsFile string) (*firebaseSenderImpl, error) {
|
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
38
server/server_firebase_dummy.go
Normal file
38
server/server_firebase_dummy.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//go:build nofirebase
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"heckel.io/ntfy/v2/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FirebaseAvailable is a constant used to indicate that Firebase support is available.
|
||||||
|
// It can be disabled with the 'nofirebase' build tag.
|
||||||
|
FirebaseAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errFirebaseNotAvailable = errors.New("Firebase not available")
|
||||||
|
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
||||||
|
)
|
||||||
|
|
||||||
|
type firebaseClient struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||||
|
return errFirebaseNotAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
type firebaseSender interface {
|
||||||
|
Send(m string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||||
|
return nil, errFirebaseNotAvailable
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nofirebase
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nopayments
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,6 +14,7 @@ import (
|
|||||||
"github.com/stripe/stripe-go/v74/subscription"
|
"github.com/stripe/stripe-go/v74/subscription"
|
||||||
"github.com/stripe/stripe-go/v74/webhook"
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -22,7 +25,7 @@ import (
|
|||||||
|
|
||||||
// Payments in ntfy are done via Stripe.
|
// Payments in ntfy are done via Stripe.
|
||||||
//
|
//
|
||||||
// Pretty much all payments related things are in this file. The following processes
|
// Pretty much all payments-related things are in this file. The following processes
|
||||||
// handle payments:
|
// handle payments:
|
||||||
//
|
//
|
||||||
// - Checkout:
|
// - Checkout:
|
||||||
@@ -464,8 +467,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
|||||||
billing := &user.Billing{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: customerID,
|
StripeCustomerID: customerID,
|
||||||
StripeSubscriptionID: subscriptionID,
|
StripeSubscriptionID: subscriptionID,
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
StripeSubscriptionStatus: payments.SubscriptionStatus(status),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||||
}
|
}
|
||||||
|
|||||||
47
server/server_payments_dummy.go
Normal file
47
server/server_payments_dummy.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//go:build nopayments
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stripeAPI interface {
|
||||||
|
CancelSubscription(id string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStripeAPI() stripeAPI {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) fetchStripePrices() (map[string]int64, error) {
|
||||||
|
return nil, errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nopayments
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,6 +8,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -345,8 +348,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
||||||
@@ -362,8 +365,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
|||||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
|
||||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
require.Equal(t, int64(0), u.Stats.Messages)
|
require.Equal(t, int64(0), u.Stats.Messages)
|
||||||
@@ -473,8 +476,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
billing := &user.Billing{
|
billing := &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||||
}
|
}
|
||||||
@@ -517,10 +520,10 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||||
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalYear), u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||||
|
|
||||||
// Verify that reservations were deleted
|
// Verify that reservations were deleted
|
||||||
r, err := s.userManager.Reservations("phil")
|
r, err := s.userManager.Reservations("phil")
|
||||||
@@ -580,8 +583,8 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
||||||
StripeCustomerID: "acct_5555",
|
StripeCustomerID: "acct_5555",
|
||||||
StripeSubscriptionID: "sub_1234",
|
StripeSubscriptionID: "sub_1234",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -598,7 +601,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
|||||||
require.Nil(t, u.Tier)
|
require.Nil(t, u.Tier)
|
||||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
_ "embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,7 +23,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/SherClockHolmes/webpush-go"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -280,30 +280,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
|||||||
rr = request(t, s2, "GET", "/app.html", "", nil)
|
rr = request(t, s2, "GET", "/app.html", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_WebPushEnabled(t *testing.T) {
|
|
||||||
conf := newTestConfig(t)
|
|
||||||
conf.WebRoot = "" // Disable web app
|
|
||||||
s := newTestServer(t, conf)
|
|
||||||
|
|
||||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 404, rr.Code)
|
|
||||||
|
|
||||||
conf2 := newTestConfig(t)
|
|
||||||
s2 := newTestServer(t, conf2)
|
|
||||||
|
|
||||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 404, rr.Code)
|
|
||||||
|
|
||||||
conf3 := newTestConfigWithWebPush(t)
|
|
||||||
s3 := newTestServer(t, conf3)
|
|
||||||
|
|
||||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
|
||||||
require.Equal(t, 200, rr.Code)
|
|
||||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.AttachmentCacheDir = "" // Disable attachments
|
c.AttachmentCacheDir = "" // Disable attachments
|
||||||
@@ -2917,7 +2893,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
m := toMessage(t, response.Body.String())
|
m := toMessage(t, response.Body.String())
|
||||||
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com\n", m.Message)
|
require.Equal(t, "Severe URLs:\n- https://severe1.com\n- https://severe2.com", m.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||||
@@ -2970,8 +2946,7 @@ Labels:
|
|||||||
Annotations:
|
Annotations:
|
||||||
- summary = 15m load average too high
|
- summary = 15m load average too high
|
||||||
Source: localhost:3000/alerting/grafana/NW9oDw-4z/view
|
Source: localhost:3000/alerting/grafana/NW9oDw-4z/view
|
||||||
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter
|
Silence: localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter`, m.Message)
|
||||||
`, m.Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_MessageTemplate_GitHub(t *testing.T) {
|
func TestServer_MessageTemplate_GitHub(t *testing.T) {
|
||||||
@@ -3024,12 +2999,223 @@ template ""}}`,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_SprigFunctions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
bodies := []string{
|
||||||
|
`{"foo":"bar","nested":{"title":"here"}}`,
|
||||||
|
`{"topic":"ntfy-test"}`,
|
||||||
|
`{"topic":"another-topic"}`,
|
||||||
|
}
|
||||||
|
templates := []string{
|
||||||
|
`{{.foo | upper}} is {{.nested.title | repeat 3}}`,
|
||||||
|
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
|
||||||
|
`{{if hasPrefix "ntfy-" .topic}}Topic: {{trimPrefix "ntfy-" .topic}}{{ else }}Topic: {{.topic}}{{end}}`,
|
||||||
|
}
|
||||||
|
targets := []string{
|
||||||
|
`BAR is hereherehere`,
|
||||||
|
`Topic: test`,
|
||||||
|
`Topic: another-topic`,
|
||||||
|
}
|
||||||
|
for i, body := range bodies {
|
||||||
|
template := templates[i]
|
||||||
|
target := targets[i]
|
||||||
|
t.Run(template, func(t *testing.T) {
|
||||||
|
response := request(t, s, "PUT", `/mytopic`, body, map[string]string{
|
||||||
|
"Template": "yes",
|
||||||
|
"Message": template,
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, target, m.Message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_UnsafeSprigFunctions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{ env "PATH" }}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40043, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_InlineNewlines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{"New\nlines"}}`,
|
||||||
|
"X-Title": `{{"New\nlines"}}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, `New
|
||||||
|
lines`, m.Message)
|
||||||
|
require.Equal(t, `New
|
||||||
|
lines`, m.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_InlineNewlinesOutsideOfTemplate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar","food":"bag"}`, map[string]string{
|
||||||
|
"X-Message": `{{.foo}}{{"\n"}}{{.food}}`,
|
||||||
|
"X-Title": `{{.food}}{{"\n"}}{{.foo}}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, `bar
|
||||||
|
bag`, m.Message)
|
||||||
|
require.Equal(t, `bag
|
||||||
|
bar`, m.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_TemplateFileNewlines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.TemplateDir = t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "newline.yml"), []byte(`
|
||||||
|
title: |
|
||||||
|
{{.food}}{{"\n"}}{{.foo}}
|
||||||
|
message: |
|
||||||
|
{{.foo}}{{"\n"}}{{.food}}
|
||||||
|
`), 0644))
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "POST", "/mytopic?template=newline", `{"foo":"bar","food":"bag"}`, nil)
|
||||||
|
fmt.Println(response.Body.String())
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, `bar
|
||||||
|
bag`, m.Message)
|
||||||
|
require.Equal(t, `bag
|
||||||
|
bar`, m.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed testdata/webhook_github_comment_created.json
|
||||||
|
githubCommentCreatedJSON string
|
||||||
|
|
||||||
|
//go:embed testdata/webhook_github_issue_opened.json
|
||||||
|
githubIssueOpenedJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_FromNamedTemplate_GitHubCommentCreated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic?template=github", githubCommentCreatedJSON, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "💬 New comment on issue #1389 instant alerts without Pull to refresh", m.Title)
|
||||||
|
require.Equal(t, `Commenter: https://github.com/wunter8
|
||||||
|
Repository: https://github.com/binwiederhier/ntfy
|
||||||
|
Comment link: https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289
|
||||||
|
|
||||||
|
Comment:
|
||||||
|
These are the things you need to do to get iOS push notifications to work:
|
||||||
|
1. open a browser to the web app of your ntfy instance and copy the URL (including "http://" or "https://", your domain or IP address, and any ports, and excluding any trailing slashes)
|
||||||
|
2. put the URL you copied in the ntfy `+"`"+`base-url`+"`"+` config in server.yml or NTFY_BASE_URL in env variables
|
||||||
|
3. put the URL you copied in the default server URL setting in the iOS ntfy app
|
||||||
|
4. set `+"`"+`upstream-base-url`+"`"+` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to "https://ntfy.sh" (without a trailing slash)`, m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "🐛 Issue opened: #1391 http 500 error (ntfy error 50001)", m.Title)
|
||||||
|
require.Equal(t, `Opened by: https://github.com/TheUser-dev
|
||||||
|
Repository: https://github.com/binwiederhier/ntfy
|
||||||
|
Issue link: https://github.com/binwiederhier/ntfy/issues/1391
|
||||||
|
Labels: 🪲 bug
|
||||||
|
|
||||||
|
Description:
|
||||||
|
:lady_beetle: **Describe the bug**
|
||||||
|
When sending a notification (especially when it happens with multiple requests) this error occurs
|
||||||
|
|
||||||
|
:computer: **Components impacted**
|
||||||
|
ntfy server 2.13.0 in docker, debian 12 arm64
|
||||||
|
|
||||||
|
:bulb: **Screenshots and/or logs**
|
||||||
|
`+"```"+`
|
||||||
|
closed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)
|
||||||
|
`+"```"+`
|
||||||
|
|
||||||
|
:crystal_ball: **Additional context**
|
||||||
|
Looks like this has already been fixed by #498, regression?`, m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_FromNamedTemplate_GitHubIssueOpened_OverrideConfigTemplate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.TemplateDir = t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(c.TemplateDir, "github.yml"), []byte(`
|
||||||
|
title: |
|
||||||
|
Custom title: action={{ .action }} trunctitle={{ .issue.title | trunc 10 }}
|
||||||
|
message: |
|
||||||
|
Custom message {{ .issue.number }}
|
||||||
|
`), 0644))
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "POST", "/mytopic?template=github", githubIssueOpenedJSON, nil)
|
||||||
|
fmt.Println(response.Body.String())
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "Custom title: action=opened trunctitle=http 500 e", m.Title)
|
||||||
|
require.Equal(t, "Custom message 1391", m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{ repeat 9999 "mystring" }}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{ repeat 10001 "mystring" }}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_Until100_000(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||||
conf.AttachmentCacheDir = t.TempDir()
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
|
conf.TemplateDir = t.TempDir()
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3046,17 +3232,6 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
|
|||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfigWithWebPush(t *testing.T) *Config {
|
|
||||||
conf := newTestConfig(t)
|
|
||||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
|
||||||
require.Nil(t, err)
|
|
||||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
|
||||||
conf.WebPushEmailAddress = "testing@example.com"
|
|
||||||
conf.WebPushPrivateKey = privateKey
|
|
||||||
conf.WebPushPublicKey = publicKey
|
|
||||||
return conf
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T, config *Config) *Server {
|
func newTestServer(t *testing.T, config *Config) *Server {
|
||||||
server, err := New(config)
|
server, err := New(config)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build !nowebpush
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +15,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
||||||
|
// It can be disabled with the 'nowebpush' build tag.
|
||||||
|
WebPushAvailable = true
|
||||||
|
|
||||||
webPushTopicSubscribeLimit = 50
|
webPushTopicSubscribeLimit = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
29
server/server_webpush_dummy.go
Normal file
29
server/server_webpush_dummy.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
//go:build nowebpush
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WebPushAvailable is a constant used to indicate that WebPush support is available.
|
||||||
|
// It can be disabled with the 'nowebpush' build tag.
|
||||||
|
WebPushAvailable = false
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||||
|
// Nothing to see here
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) pruneAndNotifyWebPushSubscriptions() {
|
||||||
|
// Nothing to see here
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
//go:build !nowebpush
|
||||||
|
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/SherClockHolmes/webpush-go"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
@@ -10,6 +13,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,6 +24,28 @@ const (
|
|||||||
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestServer_WebPush_Enabled(t *testing.T) {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.WebRoot = "" // Disable web app
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
conf2 := newTestConfig(t)
|
||||||
|
s2 := newTestServer(t, conf2)
|
||||||
|
|
||||||
|
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 404, rr.Code)
|
||||||
|
|
||||||
|
conf3 := newTestConfigWithWebPush(t)
|
||||||
|
s3 := newTestServer(t, conf3)
|
||||||
|
|
||||||
|
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
}
|
||||||
func TestServer_WebPush_Disabled(t *testing.T) {
|
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -254,3 +280,14 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Len(t, subs, expectedLength)
|
require.Len(t, subs, expectedLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||||
|
require.Nil(t, err)
|
||||||
|
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||||
|
conf.WebPushEmailAddress = "testing@example.com"
|
||||||
|
conf.WebPushPrivateKey = privateKey
|
||||||
|
conf.WebPushPublicKey = publicKey
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|||||||
@@ -192,12 +192,12 @@ func (s *smtpSession) publishMessage(m *message) error {
|
|||||||
// Call HTTP handler with fake HTTP request
|
// Call HTTP handler with fake HTTP request
|
||||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||||
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||||
req.RequestURI = "/" + m.Topic // just for the logs
|
|
||||||
req.RemoteAddr = remoteAddr // rate limiting!!
|
|
||||||
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
req.RequestURI = "/" + m.Topic // just for the logs
|
||||||
|
req.RemoteAddr = remoteAddr // rate limiting!!
|
||||||
|
req.Header.Set(s.backend.config.ProxyForwardedHeader, remoteAddr) // Set X-Forwarded-For header
|
||||||
if m.Title != "" {
|
if m.Title != "" {
|
||||||
req.Header.Set("Title", m.Title)
|
req.Header.Set("Title", m.Title)
|
||||||
}
|
}
|
||||||
|
|||||||
27
server/templates/alertmanager.yml
Normal file
27
server/templates/alertmanager.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
title: |
|
||||||
|
{{- if eq .status "firing" }}
|
||||||
|
🚨 Alert: {{ (first .alerts).labels.alertname }}
|
||||||
|
{{- else if eq .status "resolved" }}
|
||||||
|
✅ Resolved: {{ (first .alerts).labels.alertname }}
|
||||||
|
{{- else }}
|
||||||
|
{{ fail "Unsupported Alertmanager status." }}
|
||||||
|
{{- end }}
|
||||||
|
message: |
|
||||||
|
Status: {{ .status | title }}
|
||||||
|
Receiver: {{ .receiver }}
|
||||||
|
|
||||||
|
{{- range .alerts }}
|
||||||
|
Alert: {{ .labels.alertname }}
|
||||||
|
Instance: {{ .labels.instance }}
|
||||||
|
Severity: {{ .labels.severity }}
|
||||||
|
Starts at: {{ .startsAt }}
|
||||||
|
{{- if .endsAt }}Ends at: {{ .endsAt }}{{ end }}
|
||||||
|
{{- if .annotations.summary }}
|
||||||
|
Summary: {{ .annotations.summary }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .annotations.description }}
|
||||||
|
Description: {{ .annotations.description }}
|
||||||
|
{{- end }}
|
||||||
|
Source: {{ .generatorURL }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
57
server/templates/github.yml
Normal file
57
server/templates/github.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
title: |
|
||||||
|
{{- if and .starred_at (eq .action "created")}}
|
||||||
|
⭐ {{ .sender.login }} starred {{ .repository.name }}
|
||||||
|
|
||||||
|
{{- else if and .repository (eq .action "started")}}
|
||||||
|
👀 {{ .sender.login }} started watching {{ .repository.name }}
|
||||||
|
|
||||||
|
{{- else if and .comment (eq .action "created") }}
|
||||||
|
💬 New comment on issue #{{ .issue.number }} {{ .issue.title }}
|
||||||
|
|
||||||
|
{{- else if .pull_request }}
|
||||||
|
🔀 Pull request {{ .action }}: #{{ .pull_request.number }} {{ .pull_request.title }}
|
||||||
|
|
||||||
|
{{- else if .issue }}
|
||||||
|
🐛 Issue {{ .action }}: #{{ .issue.number }} {{ .issue.title }}
|
||||||
|
|
||||||
|
{{- else }}
|
||||||
|
{{ fail "Unsupported GitHub event type or action." }}
|
||||||
|
{{- end }}
|
||||||
|
message: |
|
||||||
|
{{ if and .starred_at (eq .action "created")}}
|
||||||
|
Stargazer: {{ .sender.html_url }}
|
||||||
|
Repository: {{ .repository.html_url }}
|
||||||
|
|
||||||
|
{{- else if and .repository (eq .action "started")}}
|
||||||
|
Watcher: {{ .sender.html_url }}
|
||||||
|
Repository: {{ .repository.html_url }}
|
||||||
|
|
||||||
|
{{- else if and .comment (eq .action "created") }}
|
||||||
|
Commenter: {{ .comment.user.html_url }}
|
||||||
|
Repository: {{ .repository.html_url }}
|
||||||
|
Comment link: {{ .comment.html_url }}
|
||||||
|
{{ if .comment.body }}
|
||||||
|
Comment:
|
||||||
|
{{ .comment.body | trunc 2000 }}{{ end }}
|
||||||
|
|
||||||
|
{{- else if .pull_request }}
|
||||||
|
Branch: {{ .pull_request.head.ref }} → {{ .pull_request.base.ref }}
|
||||||
|
{{ .action | title }} by: {{ .pull_request.user.html_url }}
|
||||||
|
Repository: {{ .repository.html_url }}
|
||||||
|
Pull request: {{ .pull_request.html_url }}
|
||||||
|
{{ if .pull_request.body }}
|
||||||
|
Description:
|
||||||
|
{{ .pull_request.body | trunc 2000 }}{{ end }}
|
||||||
|
|
||||||
|
{{- else if .issue }}
|
||||||
|
{{ .action | title }} by: {{ .issue.user.html_url }}
|
||||||
|
Repository: {{ .repository.html_url }}
|
||||||
|
Issue link: {{ .issue.html_url }}
|
||||||
|
{{ if .issue.labels }}Labels: {{ range .issue.labels }}{{ .name }} {{ end }}{{ end }}
|
||||||
|
{{ if .issue.body }}
|
||||||
|
Description:
|
||||||
|
{{ .issue.body | trunc 2000 }}{{ end }}
|
||||||
|
|
||||||
|
{{- else }}
|
||||||
|
{{ fail "Unsupported GitHub event type or action." }}
|
||||||
|
{{- end }}
|
||||||
10
server/templates/grafana.yml
Normal file
10
server/templates/grafana.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
title: |
|
||||||
|
{{- if eq .status "firing" }}
|
||||||
|
🚨 {{ .title | default "Alert firing" }}
|
||||||
|
{{- else if eq .status "resolved" }}
|
||||||
|
✅ {{ .title | default "Alert resolved" }}
|
||||||
|
{{- else }}
|
||||||
|
⚠️ Unknown alert: {{ .title | default "Alert" }}
|
||||||
|
{{- end }}
|
||||||
|
message: |
|
||||||
|
{{ .message | trunc 2000 }}
|
||||||
33
server/testdata/webhook_alertmanager_firing.json
vendored
Normal file
33
server/testdata/webhook_alertmanager_firing.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": "4",
|
||||||
|
"groupKey": "...",
|
||||||
|
"status": "firing",
|
||||||
|
"receiver": "webhook-receiver",
|
||||||
|
"groupLabels": {
|
||||||
|
"alertname": "HighCPUUsage"
|
||||||
|
},
|
||||||
|
"commonLabels": {
|
||||||
|
"alertname": "HighCPUUsage",
|
||||||
|
"instance": "server01",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
"commonAnnotations": {
|
||||||
|
"summary": "High CPU usage detected"
|
||||||
|
},
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"status": "firing",
|
||||||
|
"labels": {
|
||||||
|
"alertname": "HighCPUUsage",
|
||||||
|
"instance": "server01",
|
||||||
|
"severity": "critical"
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"summary": "High CPU usage detected"
|
||||||
|
},
|
||||||
|
"startsAt": "2025-07-17T07:00:00Z",
|
||||||
|
"endsAt": "0001-01-01T00:00:00Z",
|
||||||
|
"generatorURL": "http://prometheus.local/graph?g0.expr=..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
261
server/testdata/webhook_github_comment_created.json
vendored
Normal file
261
server/testdata/webhook_github_comment_created.json
vendored
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
{
|
||||||
|
"action": "created",
|
||||||
|
"issue": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389",
|
||||||
|
"repository_url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/labels{/name}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/comments",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/events",
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy/issues/1389",
|
||||||
|
"id": 3230655753,
|
||||||
|
"node_id": "I_kwDOGRBhi87Aj-UJ",
|
||||||
|
"number": 1389,
|
||||||
|
"title": "instant alerts without Pull to refresh",
|
||||||
|
"user": {
|
||||||
|
"login": "edbraunh",
|
||||||
|
"id": 8795846,
|
||||||
|
"node_id": "MDQ6VXNlcjg3OTU4NDY=",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8795846?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/edbraunh",
|
||||||
|
"html_url": "https://github.com/edbraunh",
|
||||||
|
"followers_url": "https://api.github.com/users/edbraunh/followers",
|
||||||
|
"following_url": "https://api.github.com/users/edbraunh/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/edbraunh/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/edbraunh/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/edbraunh/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/edbraunh/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/edbraunh/repos",
|
||||||
|
"events_url": "https://api.github.com/users/edbraunh/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/edbraunh/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"id": 3480884105,
|
||||||
|
"node_id": "LA_kwDOGRBhi87PehOJ",
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/labels/enhancement",
|
||||||
|
"name": "enhancement",
|
||||||
|
"color": "a2eeef",
|
||||||
|
"default": true,
|
||||||
|
"description": "New feature or request"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "open",
|
||||||
|
"locked": false,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": [
|
||||||
|
],
|
||||||
|
"milestone": null,
|
||||||
|
"comments": 3,
|
||||||
|
"created_at": "2025-07-15T03:46:30Z",
|
||||||
|
"updated_at": "2025-07-16T11:45:57Z",
|
||||||
|
"closed_at": null,
|
||||||
|
"author_association": "NONE",
|
||||||
|
"active_lock_reason": null,
|
||||||
|
"sub_issues_summary": {
|
||||||
|
"total": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"percent_completed": 0
|
||||||
|
},
|
||||||
|
"body": "Hello ntfy Team,\n\nFirst off, thank you for developing such a powerful and lightweight notification app — it’s been invaluable for receiving timely alerts.\n\nI’m a user who relies heavily on ntfy for real-time trading alerts and have noticed that while push notifications arrive instantly, the in-app alert list does not automatically refresh with new messages. Currently, I need to manually pull-to-refresh the alert list to see the latest alerts.\n\nWould it be possible to add a feature that enables automatic refreshing of the alert list as new notifications arrive? This would greatly enhance usability and streamline the user experience, especially for users monitoring time-sensitive information.\n\nThank you for considering this request. I appreciate your hard work and look forward to future updates!",
|
||||||
|
"reactions": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/reactions",
|
||||||
|
"total_count": 0,
|
||||||
|
"+1": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"laugh": 0,
|
||||||
|
"hooray": 0,
|
||||||
|
"confused": 0,
|
||||||
|
"heart": 0,
|
||||||
|
"rocket": 0,
|
||||||
|
"eyes": 0
|
||||||
|
},
|
||||||
|
"timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389/timeline",
|
||||||
|
"performed_via_github_app": null,
|
||||||
|
"state_reason": null
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289",
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy/issues/1389#issuecomment-3078214289",
|
||||||
|
"issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1389",
|
||||||
|
"id": 3078214289,
|
||||||
|
"node_id": "IC_kwDOGRBhi863edKR",
|
||||||
|
"user": {
|
||||||
|
"login": "wunter8",
|
||||||
|
"id": 8421688,
|
||||||
|
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/wunter8",
|
||||||
|
"html_url": "https://github.com/wunter8",
|
||||||
|
"followers_url": "https://api.github.com/users/wunter8/followers",
|
||||||
|
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/wunter8/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/wunter8/repos",
|
||||||
|
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/wunter8/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"created_at": "2025-07-16T11:45:57Z",
|
||||||
|
"updated_at": "2025-07-16T11:45:57Z",
|
||||||
|
"author_association": "CONTRIBUTOR",
|
||||||
|
"body": "These are the things you need to do to get iOS push notifications to work:\n1. open a browser to the web app of your ntfy instance and copy the URL (including \"http://\" or \"https://\", your domain or IP address, and any ports, and excluding any trailing slashes)\n2. put the URL you copied in the ntfy `base-url` config in server.yml or NTFY_BASE_URL in env variables\n3. put the URL you copied in the default server URL setting in the iOS ntfy app\n4. set `upstream-base-url` in server.yml or NTFY_UPSTREAM_BASE_URL in env variables to \"https://ntfy.sh\" (without a trailing slash)",
|
||||||
|
"reactions": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments/3078214289/reactions",
|
||||||
|
"total_count": 0,
|
||||||
|
"+1": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"laugh": 0,
|
||||||
|
"hooray": 0,
|
||||||
|
"confused": 0,
|
||||||
|
"heart": 0,
|
||||||
|
"rocket": 0,
|
||||||
|
"eyes": 0
|
||||||
|
},
|
||||||
|
"performed_via_github_app": null
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T10:18:34Z",
|
||||||
|
"pushed_at": "2025-07-13T13:56:19Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36740,
|
||||||
|
"stargazers_count": 25111,
|
||||||
|
"watchers_count": 25111,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 367,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 367,
|
||||||
|
"watchers": 25111,
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"login": "wunter8",
|
||||||
|
"id": 8421688,
|
||||||
|
"node_id": "MDQ6VXNlcjg0MjE2ODg=",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/8421688?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/wunter8",
|
||||||
|
"html_url": "https://github.com/wunter8",
|
||||||
|
"followers_url": "https://api.github.com/users/wunter8/followers",
|
||||||
|
"following_url": "https://api.github.com/users/wunter8/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/wunter8/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/wunter8/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/wunter8/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/wunter8/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/wunter8/repos",
|
||||||
|
"events_url": "https://api.github.com/users/wunter8/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/wunter8/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
216
server/testdata/webhook_github_issue_opened.json
vendored
Normal file
216
server/testdata/webhook_github_issue_opened.json
vendored
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
{
|
||||||
|
"action": "opened",
|
||||||
|
"issue": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391",
|
||||||
|
"repository_url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/labels{/name}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/comments",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/events",
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy/issues/1391",
|
||||||
|
"id": 3236389051,
|
||||||
|
"node_id": "I_kwDOGRBhi87A52C7",
|
||||||
|
"number": 1391,
|
||||||
|
"title": "http 500 error (ntfy error 50001)",
|
||||||
|
"user": {
|
||||||
|
"login": "TheUser-dev",
|
||||||
|
"id": 213207407,
|
||||||
|
"node_id": "U_kgDODLVJbw",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/TheUser-dev",
|
||||||
|
"html_url": "https://github.com/TheUser-dev",
|
||||||
|
"followers_url": "https://api.github.com/users/TheUser-dev/followers",
|
||||||
|
"following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/TheUser-dev/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/TheUser-dev/repos",
|
||||||
|
"events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/TheUser-dev/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"labels": [
|
||||||
|
{
|
||||||
|
"id": 3480884102,
|
||||||
|
"node_id": "LA_kwDOGRBhi87PehOG",
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug",
|
||||||
|
"name": "🪲 bug",
|
||||||
|
"color": "d73a4a",
|
||||||
|
"default": false,
|
||||||
|
"description": "Something isn't working"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"state": "open",
|
||||||
|
"locked": false,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": [
|
||||||
|
],
|
||||||
|
"milestone": null,
|
||||||
|
"comments": 0,
|
||||||
|
"created_at": "2025-07-16T15:20:56Z",
|
||||||
|
"updated_at": "2025-07-16T15:20:56Z",
|
||||||
|
"closed_at": null,
|
||||||
|
"author_association": "NONE",
|
||||||
|
"active_lock_reason": null,
|
||||||
|
"sub_issues_summary": {
|
||||||
|
"total": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"percent_completed": 0
|
||||||
|
},
|
||||||
|
"body": ":lady_beetle: **Describe the bug**\nWhen sending a notification (especially when it happens with multiple requests) this error occurs\n\n:computer: **Components impacted**\nntfy server 2.13.0 in docker, debian 12 arm64\n\n:bulb: **Screenshots and/or logs**\n```\nclosed with HTTP 500 (ntfy error 50001) (error=database table is locked, http_method=POST, http_path=/_matrix/push/v1/notify, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=30, visitor_id=ip:<edited>, visitor_ip=<edited>, visitor_messages=448, visitor_messages_limit=17280, visitor_messages_remaining=16832, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=57.049697891799994, visitor_seen=2025-07-16T15:06:35.429Z)\n```\n\n:crystal_ball: **Additional context**\nLooks like this has already been fixed by #498, regression?\n",
|
||||||
|
"reactions": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/reactions",
|
||||||
|
"total_count": 0,
|
||||||
|
"+1": 0,
|
||||||
|
"-1": 0,
|
||||||
|
"laugh": 0,
|
||||||
|
"hooray": 0,
|
||||||
|
"confused": 0,
|
||||||
|
"heart": 0,
|
||||||
|
"rocket": 0,
|
||||||
|
"eyes": 0
|
||||||
|
},
|
||||||
|
"timeline_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1391/timeline",
|
||||||
|
"performed_via_github_app": null,
|
||||||
|
"state_reason": null
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T14:54:16Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36831,
|
||||||
|
"stargazers_count": 25112,
|
||||||
|
"watchers_count": 25112,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 369,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 369,
|
||||||
|
"watchers": 25112,
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"login": "TheUser-dev",
|
||||||
|
"id": 213207407,
|
||||||
|
"node_id": "U_kgDODLVJbw",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/213207407?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/TheUser-dev",
|
||||||
|
"html_url": "https://github.com/TheUser-dev",
|
||||||
|
"followers_url": "https://api.github.com/users/TheUser-dev/followers",
|
||||||
|
"following_url": "https://api.github.com/users/TheUser-dev/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/TheUser-dev/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/TheUser-dev/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/TheUser-dev/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/TheUser-dev/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/TheUser-dev/repos",
|
||||||
|
"events_url": "https://api.github.com/users/TheUser-dev/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/TheUser-dev/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
541
server/testdata/webhook_github_pr_opened.json
vendored
Normal file
541
server/testdata/webhook_github_pr_opened.json
vendored
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
{
|
||||||
|
"action": "opened",
|
||||||
|
"number": 1390,
|
||||||
|
"pull_request": {
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390",
|
||||||
|
"id": 2670425869,
|
||||||
|
"node_id": "PR_kwDOGRBhi86fK3cN",
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy/pull/1390",
|
||||||
|
"diff_url": "https://github.com/binwiederhier/ntfy/pull/1390.diff",
|
||||||
|
"patch_url": "https://github.com/binwiederhier/ntfy/pull/1390.patch",
|
||||||
|
"issue_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390",
|
||||||
|
"number": 1390,
|
||||||
|
"state": "open",
|
||||||
|
"locked": false,
|
||||||
|
"title": "WIP Template dir",
|
||||||
|
"user": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"body": null,
|
||||||
|
"created_at": "2025-07-16T11:49:31Z",
|
||||||
|
"updated_at": "2025-07-16T11:49:31Z",
|
||||||
|
"closed_at": null,
|
||||||
|
"merged_at": null,
|
||||||
|
"merge_commit_sha": null,
|
||||||
|
"assignee": null,
|
||||||
|
"assignees": [
|
||||||
|
],
|
||||||
|
"requested_reviewers": [
|
||||||
|
],
|
||||||
|
"requested_teams": [
|
||||||
|
],
|
||||||
|
"labels": [
|
||||||
|
],
|
||||||
|
"milestone": null,
|
||||||
|
"draft": false,
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits",
|
||||||
|
"review_comments_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments",
|
||||||
|
"review_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd",
|
||||||
|
"head": {
|
||||||
|
"label": "binwiederhier:template-dir",
|
||||||
|
"ref": "template-dir",
|
||||||
|
"sha": "b1e935da45365c5e7e731d544a1ad4c7ea3643cd",
|
||||||
|
"user": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T10:18:34Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36740,
|
||||||
|
"stargazers_count": 25111,
|
||||||
|
"watchers_count": 25111,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 368,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 368,
|
||||||
|
"watchers": 25111,
|
||||||
|
"default_branch": "main",
|
||||||
|
"allow_squash_merge": true,
|
||||||
|
"allow_merge_commit": true,
|
||||||
|
"allow_rebase_merge": true,
|
||||||
|
"allow_auto_merge": true,
|
||||||
|
"delete_branch_on_merge": false,
|
||||||
|
"allow_update_branch": false,
|
||||||
|
"use_squash_pr_title_as_default": false,
|
||||||
|
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||||
|
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||||
|
"merge_commit_message": "PR_TITLE",
|
||||||
|
"merge_commit_title": "MERGE_MESSAGE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base": {
|
||||||
|
"label": "binwiederhier:main",
|
||||||
|
"ref": "main",
|
||||||
|
"sha": "81a486adc11fe24efcbedefb28ae946028597c2f",
|
||||||
|
"user": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T10:18:34Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36740,
|
||||||
|
"stargazers_count": 25111,
|
||||||
|
"watchers_count": 25111,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 368,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 368,
|
||||||
|
"watchers": 25111,
|
||||||
|
"default_branch": "main",
|
||||||
|
"allow_squash_merge": true,
|
||||||
|
"allow_merge_commit": true,
|
||||||
|
"allow_rebase_merge": true,
|
||||||
|
"allow_auto_merge": true,
|
||||||
|
"delete_branch_on_merge": false,
|
||||||
|
"allow_update_branch": false,
|
||||||
|
"use_squash_pr_title_as_default": false,
|
||||||
|
"squash_merge_commit_message": "COMMIT_MESSAGES",
|
||||||
|
"squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
|
||||||
|
"merge_commit_message": "PR_TITLE",
|
||||||
|
"merge_commit_title": "MERGE_MESSAGE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"_links": {
|
||||||
|
"self": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390"
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"href": "https://github.com/binwiederhier/ntfy/pull/1390"
|
||||||
|
},
|
||||||
|
"issue": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/issues/1390/comments"
|
||||||
|
},
|
||||||
|
"review_comments": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/comments"
|
||||||
|
},
|
||||||
|
"review_comment": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/comments{/number}"
|
||||||
|
},
|
||||||
|
"commits": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/pulls/1390/commits"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"href": "https://api.github.com/repos/binwiederhier/ntfy/statuses/b1e935da45365c5e7e731d544a1ad4c7ea3643cd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"author_association": "OWNER",
|
||||||
|
"auto_merge": null,
|
||||||
|
"active_lock_reason": null,
|
||||||
|
"merged": false,
|
||||||
|
"mergeable": null,
|
||||||
|
"rebaseable": null,
|
||||||
|
"mergeable_state": "unknown",
|
||||||
|
"merged_by": null,
|
||||||
|
"comments": 0,
|
||||||
|
"review_comments": 0,
|
||||||
|
"maintainer_can_modify": false,
|
||||||
|
"commits": 7,
|
||||||
|
"additions": 5506,
|
||||||
|
"deletions": 42,
|
||||||
|
"changed_files": 58
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T10:18:34Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36740,
|
||||||
|
"stargazers_count": 25111,
|
||||||
|
"watchers_count": 25111,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 368,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 368,
|
||||||
|
"watchers": 25111,
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
141
server/testdata/webhook_github_star_created.json
vendored
Normal file
141
server/testdata/webhook_github_star_created.json
vendored
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"action": "created",
|
||||||
|
"starred_at": "2025-07-16T12:57:43Z",
|
||||||
|
"repository": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T12:57:43Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36831,
|
||||||
|
"stargazers_count": 25112,
|
||||||
|
"watchers_count": 25112,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 368,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 368,
|
||||||
|
"watchers": 25112,
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"login": "mbilby",
|
||||||
|
"id": 51273322,
|
||||||
|
"node_id": "MDQ6VXNlcjUxMjczMzIy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/mbilby",
|
||||||
|
"html_url": "https://github.com/mbilby",
|
||||||
|
"followers_url": "https://api.github.com/users/mbilby/followers",
|
||||||
|
"following_url": "https://api.github.com/users/mbilby/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/mbilby/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/mbilby/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/mbilby/repos",
|
||||||
|
"events_url": "https://api.github.com/users/mbilby/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/mbilby/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
139
server/testdata/webhook_github_watch_created.json
vendored
Normal file
139
server/testdata/webhook_github_watch_created.json
vendored
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"action": "started",
|
||||||
|
"repository": {
|
||||||
|
"id": 420503947,
|
||||||
|
"node_id": "R_kgDOGRBhiw",
|
||||||
|
"name": "ntfy",
|
||||||
|
"full_name": "binwiederhier/ntfy",
|
||||||
|
"private": false,
|
||||||
|
"owner": {
|
||||||
|
"login": "binwiederhier",
|
||||||
|
"id": 664597,
|
||||||
|
"node_id": "MDQ6VXNlcjY2NDU5Nw==",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/664597?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/binwiederhier",
|
||||||
|
"html_url": "https://github.com/binwiederhier",
|
||||||
|
"followers_url": "https://api.github.com/users/binwiederhier/followers",
|
||||||
|
"following_url": "https://api.github.com/users/binwiederhier/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/binwiederhier/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/binwiederhier/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/binwiederhier/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/binwiederhier/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/binwiederhier/repos",
|
||||||
|
"events_url": "https://api.github.com/users/binwiederhier/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/binwiederhier/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
},
|
||||||
|
"html_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"description": "Send push notifications to your phone or desktop using PUT/POST",
|
||||||
|
"fork": false,
|
||||||
|
"url": "https://api.github.com/repos/binwiederhier/ntfy",
|
||||||
|
"forks_url": "https://api.github.com/repos/binwiederhier/ntfy/forks",
|
||||||
|
"keys_url": "https://api.github.com/repos/binwiederhier/ntfy/keys{/key_id}",
|
||||||
|
"collaborators_url": "https://api.github.com/repos/binwiederhier/ntfy/collaborators{/collaborator}",
|
||||||
|
"teams_url": "https://api.github.com/repos/binwiederhier/ntfy/teams",
|
||||||
|
"hooks_url": "https://api.github.com/repos/binwiederhier/ntfy/hooks",
|
||||||
|
"issue_events_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/events{/number}",
|
||||||
|
"events_url": "https://api.github.com/repos/binwiederhier/ntfy/events",
|
||||||
|
"assignees_url": "https://api.github.com/repos/binwiederhier/ntfy/assignees{/user}",
|
||||||
|
"branches_url": "https://api.github.com/repos/binwiederhier/ntfy/branches{/branch}",
|
||||||
|
"tags_url": "https://api.github.com/repos/binwiederhier/ntfy/tags",
|
||||||
|
"blobs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/blobs{/sha}",
|
||||||
|
"git_tags_url": "https://api.github.com/repos/binwiederhier/ntfy/git/tags{/sha}",
|
||||||
|
"git_refs_url": "https://api.github.com/repos/binwiederhier/ntfy/git/refs{/sha}",
|
||||||
|
"trees_url": "https://api.github.com/repos/binwiederhier/ntfy/git/trees{/sha}",
|
||||||
|
"statuses_url": "https://api.github.com/repos/binwiederhier/ntfy/statuses/{sha}",
|
||||||
|
"languages_url": "https://api.github.com/repos/binwiederhier/ntfy/languages",
|
||||||
|
"stargazers_url": "https://api.github.com/repos/binwiederhier/ntfy/stargazers",
|
||||||
|
"contributors_url": "https://api.github.com/repos/binwiederhier/ntfy/contributors",
|
||||||
|
"subscribers_url": "https://api.github.com/repos/binwiederhier/ntfy/subscribers",
|
||||||
|
"subscription_url": "https://api.github.com/repos/binwiederhier/ntfy/subscription",
|
||||||
|
"commits_url": "https://api.github.com/repos/binwiederhier/ntfy/commits{/sha}",
|
||||||
|
"git_commits_url": "https://api.github.com/repos/binwiederhier/ntfy/git/commits{/sha}",
|
||||||
|
"comments_url": "https://api.github.com/repos/binwiederhier/ntfy/comments{/number}",
|
||||||
|
"issue_comment_url": "https://api.github.com/repos/binwiederhier/ntfy/issues/comments{/number}",
|
||||||
|
"contents_url": "https://api.github.com/repos/binwiederhier/ntfy/contents/{+path}",
|
||||||
|
"compare_url": "https://api.github.com/repos/binwiederhier/ntfy/compare/{base}...{head}",
|
||||||
|
"merges_url": "https://api.github.com/repos/binwiederhier/ntfy/merges",
|
||||||
|
"archive_url": "https://api.github.com/repos/binwiederhier/ntfy/{archive_format}{/ref}",
|
||||||
|
"downloads_url": "https://api.github.com/repos/binwiederhier/ntfy/downloads",
|
||||||
|
"issues_url": "https://api.github.com/repos/binwiederhier/ntfy/issues{/number}",
|
||||||
|
"pulls_url": "https://api.github.com/repos/binwiederhier/ntfy/pulls{/number}",
|
||||||
|
"milestones_url": "https://api.github.com/repos/binwiederhier/ntfy/milestones{/number}",
|
||||||
|
"notifications_url": "https://api.github.com/repos/binwiederhier/ntfy/notifications{?since,all,participating}",
|
||||||
|
"labels_url": "https://api.github.com/repos/binwiederhier/ntfy/labels{/name}",
|
||||||
|
"releases_url": "https://api.github.com/repos/binwiederhier/ntfy/releases{/id}",
|
||||||
|
"deployments_url": "https://api.github.com/repos/binwiederhier/ntfy/deployments",
|
||||||
|
"created_at": "2021-10-23T19:25:32Z",
|
||||||
|
"updated_at": "2025-07-16T12:57:43Z",
|
||||||
|
"pushed_at": "2025-07-16T11:49:26Z",
|
||||||
|
"git_url": "git://github.com/binwiederhier/ntfy.git",
|
||||||
|
"ssh_url": "git@github.com:binwiederhier/ntfy.git",
|
||||||
|
"clone_url": "https://github.com/binwiederhier/ntfy.git",
|
||||||
|
"svn_url": "https://github.com/binwiederhier/ntfy",
|
||||||
|
"homepage": "https://ntfy.sh",
|
||||||
|
"size": 36831,
|
||||||
|
"stargazers_count": 25112,
|
||||||
|
"watchers_count": 25112,
|
||||||
|
"language": "Go",
|
||||||
|
"has_issues": true,
|
||||||
|
"has_projects": true,
|
||||||
|
"has_downloads": true,
|
||||||
|
"has_wiki": true,
|
||||||
|
"has_pages": false,
|
||||||
|
"has_discussions": false,
|
||||||
|
"forks_count": 984,
|
||||||
|
"mirror_url": null,
|
||||||
|
"archived": false,
|
||||||
|
"disabled": false,
|
||||||
|
"open_issues_count": 368,
|
||||||
|
"license": {
|
||||||
|
"key": "apache-2.0",
|
||||||
|
"name": "Apache License 2.0",
|
||||||
|
"spdx_id": "Apache-2.0",
|
||||||
|
"url": "https://api.github.com/licenses/apache-2.0",
|
||||||
|
"node_id": "MDc6TGljZW5zZTI="
|
||||||
|
},
|
||||||
|
"allow_forking": true,
|
||||||
|
"is_template": false,
|
||||||
|
"web_commit_signoff_required": false,
|
||||||
|
"topics": [
|
||||||
|
"curl",
|
||||||
|
"notifications",
|
||||||
|
"ntfy",
|
||||||
|
"ntfysh",
|
||||||
|
"pubsub",
|
||||||
|
"push-notifications",
|
||||||
|
"rest-api"
|
||||||
|
],
|
||||||
|
"visibility": "public",
|
||||||
|
"forks": 984,
|
||||||
|
"open_issues": 368,
|
||||||
|
"watchers": 25112,
|
||||||
|
"default_branch": "main"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"login": "mbilby",
|
||||||
|
"id": 51273322,
|
||||||
|
"node_id": "MDQ6VXNlcjUxMjczMzIy",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/51273322?v=4",
|
||||||
|
"gravatar_id": "",
|
||||||
|
"url": "https://api.github.com/users/mbilby",
|
||||||
|
"html_url": "https://github.com/mbilby",
|
||||||
|
"followers_url": "https://api.github.com/users/mbilby/followers",
|
||||||
|
"following_url": "https://api.github.com/users/mbilby/following{/other_user}",
|
||||||
|
"gists_url": "https://api.github.com/users/mbilby/gists{/gist_id}",
|
||||||
|
"starred_url": "https://api.github.com/users/mbilby/starred{/owner}{/repo}",
|
||||||
|
"subscriptions_url": "https://api.github.com/users/mbilby/subscriptions",
|
||||||
|
"organizations_url": "https://api.github.com/users/mbilby/orgs",
|
||||||
|
"repos_url": "https://api.github.com/users/mbilby/repos",
|
||||||
|
"events_url": "https://api.github.com/users/mbilby/events{/privacy}",
|
||||||
|
"received_events_url": "https://api.github.com/users/mbilby/received_events",
|
||||||
|
"type": "User",
|
||||||
|
"user_view_type": "public",
|
||||||
|
"site_admin": false
|
||||||
|
}
|
||||||
|
}
|
||||||
51
server/testdata/webhook_grafana_resolved.json
vendored
Normal file
51
server/testdata/webhook_grafana_resolved.json
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"receiver": "ntfy\\.example\\.com/alerts",
|
||||||
|
"status": "resolved",
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"status": "resolved",
|
||||||
|
"labels": {
|
||||||
|
"alertname": "Load avg 15m too high",
|
||||||
|
"grafana_folder": "Node alerts",
|
||||||
|
"instance": "10.108.0.2:9100",
|
||||||
|
"job": "node-exporter"
|
||||||
|
},
|
||||||
|
"annotations": {
|
||||||
|
"summary": "15m load average too high"
|
||||||
|
},
|
||||||
|
"startsAt": "2024-03-15T02:28:00Z",
|
||||||
|
"endsAt": "2024-03-15T02:42:00Z",
|
||||||
|
"generatorURL": "localhost:3000/alerting/grafana/NW9oDw-4z/view",
|
||||||
|
"fingerprint": "becbfb94bd81ef48",
|
||||||
|
"silenceURL": "localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=alertname%3DLoad+avg+15m+too+high&matcher=grafana_folder%3DNode+alerts&matcher=instance%3D10.108.0.2%3A9100&matcher=job%3Dnode-exporter",
|
||||||
|
"dashboardURL": "",
|
||||||
|
"panelURL": "",
|
||||||
|
"values": {
|
||||||
|
"B": 18.98211314475876,
|
||||||
|
"C": 0
|
||||||
|
},
|
||||||
|
"valueString": "[ var='B' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=18.98211314475876 ], [ var='C' labels={__name__=node_load15, instance=10.108.0.2:9100, job=node-exporter} value=0 ]"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groupLabels": {
|
||||||
|
"alertname": "Load avg 15m too high",
|
||||||
|
"grafana_folder": "Node alerts"
|
||||||
|
},
|
||||||
|
"commonLabels": {
|
||||||
|
"alertname": "Load avg 15m too high",
|
||||||
|
"grafana_folder": "Node alerts",
|
||||||
|
"instance": "10.108.0.2:9100",
|
||||||
|
"job": "node-exporter"
|
||||||
|
},
|
||||||
|
"commonAnnotations": {
|
||||||
|
"summary": "15m load average too high"
|
||||||
|
},
|
||||||
|
"externalURL": "localhost:3000/",
|
||||||
|
"version": "1",
|
||||||
|
"groupKey": "{}:{alertname=\"Load avg 15m too high\", grafana_folder=\"Node alerts\"}",
|
||||||
|
"truncatedAlerts": 0,
|
||||||
|
"orgId": 1,
|
||||||
|
"title": "[RESOLVED] Load avg 15m too high Node alerts (10.108.0.2:9100 node-exporter)",
|
||||||
|
"state": "ok",
|
||||||
|
"message": "**Resolved**\n\nValue: B=18.98211314475876, C=0\nLabels:\n - alertname = Load avg 15m too high\n - grafana_folder = Node alerts\n - instance = 10.108.0.2:9100\n - job = node-exporter\n"
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -246,6 +245,51 @@ func (q *queryFilter) Pass(msg *message) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// templateMode represents the mode in which templates are used
|
||||||
|
//
|
||||||
|
// It can be
|
||||||
|
// - empty: templating is disabled
|
||||||
|
// - a boolean string (yes/1/true/no/0/false): inline-templating mode
|
||||||
|
// - a filename (e.g. grafana): template mode with a file
|
||||||
|
type templateMode string
|
||||||
|
|
||||||
|
// Enabled returns true if templating is enabled
|
||||||
|
func (t templateMode) Enabled() bool {
|
||||||
|
return t != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// InlineMode returns true if inline-templating mode is enabled
|
||||||
|
func (t templateMode) InlineMode() bool {
|
||||||
|
return t.Enabled() && isBoolValue(string(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileMode returns true if file-templating mode is enabled
|
||||||
|
func (t templateMode) FileMode() bool {
|
||||||
|
return t.Enabled() && !isBoolValue(string(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileName returns the filename if file-templating mode is enabled, or an empty string otherwise
|
||||||
|
func (t templateMode) FileName() string {
|
||||||
|
if t.FileMode() {
|
||||||
|
return string(t)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// templateFile represents a template file with title and message
|
||||||
|
// It is used for file-based templates, e.g. grafana, influxdb, etc.
|
||||||
|
//
|
||||||
|
// Example YAML:
|
||||||
|
//
|
||||||
|
// title: "Alert: {{ .Title }}"
|
||||||
|
// message: |
|
||||||
|
// This is a {{ .Type }} alert.
|
||||||
|
// It can be multiline.
|
||||||
|
type templateFile struct {
|
||||||
|
Title *string `yaml:"title"`
|
||||||
|
Message *string `yaml:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiHealthResponse struct {
|
type apiHealthResponse struct {
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
@@ -316,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountTokenResponse struct {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
LastAccess int64 `json:"last_access,omitempty"`
|
LastAccess int64 `json:"last_access,omitempty"`
|
||||||
LastOrigin string `json:"last_origin,omitempty"`
|
LastOrigin string `json:"last_origin,omitempty"`
|
||||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountPhoneNumberVerifyRequest struct {
|
type apiAccountPhoneNumberVerifyRequest struct {
|
||||||
@@ -382,6 +427,7 @@ type apiAccountResponse struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
SyncTopic string `json:"sync_topic,omitempty"`
|
SyncTopic string `json:"sync_topic,omitempty"`
|
||||||
|
Provisioned bool `json:"provisioned,omitempty"`
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
|
|||||||
688
user/manager.go
688
user/manager.go
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -52,10 +51,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionDenyAll},
|
{"everyonewrite", PermissionDenyAll, false},
|
||||||
{"mytopic", PermissionReadWrite},
|
{"mytopic", PermissionReadWrite, false},
|
||||||
{"writeme", PermissionWrite},
|
{"writeme", PermissionWrite, false},
|
||||||
{"readme", PermissionRead},
|
{"readme", PermissionRead, false},
|
||||||
}, benGrants)
|
}, benGrants)
|
||||||
|
|
||||||
john, err := a.Authenticate("john", "john")
|
john, err := a.Authenticate("john", "john")
|
||||||
@@ -67,10 +66,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
|||||||
johnGrants, err := a.Grants("john")
|
johnGrants, err := a.Grants("john")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"mytopic_deny*", PermissionDenyAll},
|
{"mytopic_deny*", PermissionDenyAll, false},
|
||||||
{"mytopic_ro*", PermissionRead},
|
{"mytopic_ro*", PermissionRead, false},
|
||||||
{"mytopic*", PermissionReadWrite},
|
{"mytopic*", PermissionReadWrite, false},
|
||||||
{"*", PermissionRead},
|
{"*", PermissionRead, false},
|
||||||
}, johnGrants)
|
}, johnGrants)
|
||||||
|
|
||||||
notben, err := a.Authenticate("ben", "this is wrong")
|
notben, err := a.Authenticate("ben", "this is wrong")
|
||||||
@@ -164,8 +163,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
|||||||
require.Nil(t, a.ChangeBilling("user", &Billing{
|
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||||
StripeCustomerID: "acct_123",
|
StripeCustomerID: "acct_123",
|
||||||
StripeSubscriptionID: "sub_123",
|
StripeSubscriptionID: "sub_123",
|
||||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
StripeSubscriptionStatus: "active",
|
||||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
StripeSubscriptionInterval: "month",
|
||||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||||
}))
|
}))
|
||||||
@@ -194,7 +193,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.False(t, u.Deleted)
|
require.False(t, u.Deleted)
|
||||||
|
|
||||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
u, err = a.Authenticate("user", "pass")
|
u, err = a.Authenticate("user", "pass")
|
||||||
@@ -241,7 +240,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
|||||||
u, err := a.User("user")
|
u, err := a.User("user")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, token.Value, strings.ToLower(token.Value))
|
require.Equal(t, token.Value, strings.ToLower(token.Value))
|
||||||
}
|
}
|
||||||
@@ -277,10 +276,10 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionDenyAll},
|
{"everyonewrite", PermissionDenyAll, false},
|
||||||
{"mytopic", PermissionReadWrite},
|
{"mytopic", PermissionReadWrite, false},
|
||||||
{"writeme", PermissionWrite},
|
{"writeme", PermissionWrite, false},
|
||||||
{"readme", PermissionRead},
|
{"readme", PermissionRead, false},
|
||||||
}, benGrants)
|
}, benGrants)
|
||||||
|
|
||||||
everyone, err := a.User(Everyone)
|
everyone, err := a.User(Everyone)
|
||||||
@@ -292,8 +291,8 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
everyoneGrants, err := a.Grants(Everyone)
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, []Grant{
|
require.Equal(t, []Grant{
|
||||||
{"everyonewrite", PermissionReadWrite},
|
{"everyonewrite", PermissionReadWrite, false},
|
||||||
{"announcements", PermissionRead},
|
{"announcements", PermissionRead, false},
|
||||||
}, everyoneGrants)
|
}, everyoneGrants)
|
||||||
|
|
||||||
// Ben: Before revoking
|
// Ben: Before revoking
|
||||||
@@ -340,7 +339,7 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
func TestManager_ChangePassword(t *testing.T) {
|
func TestManager_ChangePassword(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, false))
|
||||||
require.Nil(t, a.AddUser("jane", "$2b$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
|
require.Nil(t, a.AddUser("jane", "$2a$10$OyqU72muEy7VMd1SAU2Iru5IbeSMgrtCGHu/fWLmxL1MwlijQXWbG", RoleUser, true))
|
||||||
|
|
||||||
_, err := a.Authenticate("phil", "phil")
|
_, err := a.Authenticate("phil", "phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -354,7 +353,7 @@ func TestManager_ChangePassword(t *testing.T) {
|
|||||||
_, err = a.Authenticate("phil", "newpass")
|
_, err = a.Authenticate("phil", "newpass")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
require.Nil(t, a.ChangePassword("jane", "$2b$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
|
require.Nil(t, a.ChangePassword("jane", "$2a$10$CNaCW.q1R431urlbQ5Drh.zl48TiiOeJSmZgfcswkZiPbJGQ1ApSS", true))
|
||||||
_, err = a.Authenticate("jane", "jane")
|
_, err = a.Authenticate("jane", "jane")
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
_, err = a.Authenticate("jane", "newpass")
|
_, err = a.Authenticate("jane", "newpass")
|
||||||
@@ -489,12 +488,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
|||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(benGrants))
|
require.Equal(t, 1, len(benGrants))
|
||||||
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
|
require.Equal(t, PermissionReadWrite, benGrants[0].Permission)
|
||||||
|
|
||||||
everyoneGrants, err := a.Grants(Everyone)
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, len(everyoneGrants))
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
|
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Permission)
|
||||||
|
|
||||||
benReservations, err := a.Reservations("ben")
|
benReservations, err := a.Reservations("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -523,7 +522,7 @@ func TestManager_Token_Valid(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create token for user
|
// Create token for user
|
||||||
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
require.Equal(t, "some label", token.Label)
|
require.Equal(t, "some label", token.Label)
|
||||||
@@ -586,12 +585,12 @@ func TestManager_Token_Expire(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create tokens for user
|
// Create tokens for user
|
||||||
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token1.Value)
|
require.NotEmpty(t, token1.Value)
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
||||||
|
|
||||||
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token2.Value)
|
require.NotEmpty(t, token2.Value)
|
||||||
require.NotEqual(t, token1.Value, token2.Value)
|
require.NotEqual(t, token1.Value, token2.Value)
|
||||||
@@ -638,7 +637,7 @@ func TestManager_Token_Extend(t *testing.T) {
|
|||||||
require.Equal(t, errNoTokenProvided, err)
|
require.Equal(t, errNoTokenProvided, err)
|
||||||
|
|
||||||
// Create token for user
|
// Create token for user
|
||||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
@@ -668,12 +667,12 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
|
|
||||||
// Create 2 tokens for phil
|
// Create 2 tokens for phil
|
||||||
philTokens := make([]string, 0)
|
philTokens := make([]string, 0)
|
||||||
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(phil.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
philTokens = append(philTokens, token.Value)
|
philTokens = append(philTokens, token.Value)
|
||||||
|
|
||||||
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
token, err = a.CreateToken(phil.ID, "", time.Unix(0, 0), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
philTokens = append(philTokens, token.Value)
|
philTokens = append(philTokens, token.Value)
|
||||||
@@ -682,7 +681,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
baseTime := time.Now().Add(24 * time.Hour)
|
baseTime := time.Now().Add(24 * time.Hour)
|
||||||
benTokens := make([]string, 0)
|
benTokens := make([]string, 0)
|
||||||
for i := 0; i < 62; i++ { //
|
for i := 0; i < 62; i++ { //
|
||||||
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(ben.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
benTokens = append(benTokens, token.Value)
|
benTokens = append(benTokens, token.Value)
|
||||||
@@ -731,7 +730,14 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
|
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
|
||||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
conf := &Config{
|
||||||
|
Filename: filepath.Join(t.TempDir(), "db"),
|
||||||
|
StartupQueries: "",
|
||||||
|
DefaultAccess: PermissionReadWrite,
|
||||||
|
BcryptCost: bcrypt.MinCost,
|
||||||
|
QueueWriterInterval: 1500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
@@ -773,7 +779,14 @@ func TestManager_EnqueueStats_ResetStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
||||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
|
conf := &Config{
|
||||||
|
Filename: filepath.Join(t.TempDir(), "db"),
|
||||||
|
StartupQueries: "",
|
||||||
|
DefaultAccess: PermissionReadWrite,
|
||||||
|
BcryptCost: bcrypt.MinCost,
|
||||||
|
QueueWriterInterval: 500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
@@ -781,7 +794,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
|||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified(), false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Queue token update
|
// Queue token update
|
||||||
@@ -806,7 +819,14 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_ChangeSettings(t *testing.T) {
|
func TestManager_ChangeSettings(t *testing.T) {
|
||||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
conf := &Config{
|
||||||
|
Filename: filepath.Join(t.TempDir(), "db"),
|
||||||
|
StartupQueries: "",
|
||||||
|
DefaultAccess: PermissionReadWrite,
|
||||||
|
BcryptCost: bcrypt.MinCost,
|
||||||
|
QueueWriterInterval: 1500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
|
||||||
|
|
||||||
@@ -1075,6 +1095,237 @@ func TestManager_Topic_Wildcard_With_Underscore(t *testing.T) {
|
|||||||
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopicX", PermissionWrite))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManager_WithProvisionedUsers(t *testing.T) {
|
||||||
|
f := filepath.Join(t.TempDir(), "user.db")
|
||||||
|
conf := &Config{
|
||||||
|
Filename: f,
|
||||||
|
DefaultAccess: PermissionReadWrite,
|
||||||
|
ProvisionEnabled: true,
|
||||||
|
Users: []*User{
|
||||||
|
{Name: "philuser", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
|
{Name: "philadmin", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleAdmin},
|
||||||
|
},
|
||||||
|
Access: map[string][]*Grant{
|
||||||
|
"philuser": {
|
||||||
|
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
||||||
|
{TopicPattern: "secret", Permission: PermissionRead},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tokens: map[string][]*Token{
|
||||||
|
"philuser": {
|
||||||
|
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Manually add user
|
||||||
|
require.Nil(t, a.AddUser("philmanual", "manual", RoleUser, false))
|
||||||
|
|
||||||
|
// Check that the provisioned users are there
|
||||||
|
users, err := a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Len(t, users, 4)
|
||||||
|
require.Equal(t, "philadmin", users[0].Name)
|
||||||
|
require.Equal(t, RoleAdmin, users[0].Role)
|
||||||
|
require.Equal(t, "philmanual", users[1].Name)
|
||||||
|
require.Equal(t, RoleUser, users[1].Role)
|
||||||
|
require.Equal(t, "philuser", users[2].Name)
|
||||||
|
require.Equal(t, RoleUser, users[2].Role)
|
||||||
|
require.Equal(t, "*", users[3].Name)
|
||||||
|
provisionedUserID := users[2].ID // "philuser" is the provisioned user
|
||||||
|
|
||||||
|
grants, err := a.Grants("philuser")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(grants))
|
||||||
|
require.Equal(t, "secret", grants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||||
|
require.Equal(t, "stats", grants[1].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
|
||||||
|
tokens, err := a.Tokens(provisionedUserID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(tokens))
|
||||||
|
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
|
||||||
|
require.Equal(t, "Alerts token", tokens[0].Label)
|
||||||
|
require.True(t, tokens[0].Provisioned)
|
||||||
|
|
||||||
|
// Update the token last access time and origin (so we can check that it is persisted)
|
||||||
|
lastAccessTime := time.Now().Add(time.Hour)
|
||||||
|
lastOrigin := netip.MustParseAddr("1.1.9.9")
|
||||||
|
err = execTx(a.db, func(tx *sql.Tx) error {
|
||||||
|
return a.updateTokenLastAccessTx(tx, tokens[0].Value, lastAccessTime.Unix(), lastOrigin.String())
|
||||||
|
})
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Re-open the DB (second app start)
|
||||||
|
require.Nil(t, a.db.Close())
|
||||||
|
conf.Users = []*User{
|
||||||
|
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
|
}
|
||||||
|
conf.Access = map[string][]*Grant{
|
||||||
|
"philuser": {
|
||||||
|
{TopicPattern: "stats12", Permission: PermissionReadWrite},
|
||||||
|
{TopicPattern: "secret12", Permission: PermissionRead},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conf.Tokens = map[string][]*Token{
|
||||||
|
"philuser": {
|
||||||
|
{Value: "tk_op56p8lz5bf3cxkz9je99v9oc37lo", Label: "Alerts token updated"},
|
||||||
|
{Value: "tk_u48wqendnkx9er21pqqcadlytbutx", Label: "Another token"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a, err = NewManager(conf)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Check that the provisioned users are there
|
||||||
|
users, err = a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Len(t, users, 3)
|
||||||
|
require.Equal(t, "philmanual", users[0].Name)
|
||||||
|
require.Equal(t, "philuser", users[1].Name)
|
||||||
|
require.Equal(t, RoleUser, users[1].Role)
|
||||||
|
require.Equal(t, RoleUser, users[0].Role)
|
||||||
|
require.Equal(t, "*", users[2].Name)
|
||||||
|
|
||||||
|
grants, err = a.Grants("philuser")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(grants))
|
||||||
|
require.Equal(t, "secret12", grants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||||
|
require.Equal(t, "stats12", grants[1].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
|
||||||
|
tokens, err = a.Tokens(provisionedUserID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(tokens))
|
||||||
|
require.Equal(t, "tk_op56p8lz5bf3cxkz9je99v9oc37lo", tokens[0].Value)
|
||||||
|
require.Equal(t, "Alerts token updated", tokens[0].Label)
|
||||||
|
require.Equal(t, lastAccessTime.Unix(), tokens[0].LastAccess.Unix())
|
||||||
|
require.Equal(t, lastOrigin, tokens[0].LastOrigin)
|
||||||
|
require.True(t, tokens[0].Provisioned)
|
||||||
|
require.Equal(t, "tk_u48wqendnkx9er21pqqcadlytbutx", tokens[1].Value)
|
||||||
|
require.Equal(t, "Another token", tokens[1].Label)
|
||||||
|
|
||||||
|
// Try changing provisioned user's password
|
||||||
|
require.Error(t, a.ChangePassword("philuser", "new-pass", false))
|
||||||
|
|
||||||
|
// Re-open the DB again (third app start)
|
||||||
|
require.Nil(t, a.db.Close())
|
||||||
|
conf.Users = []*User{}
|
||||||
|
conf.Access = map[string][]*Grant{}
|
||||||
|
conf.Tokens = map[string][]*Token{}
|
||||||
|
a, err = NewManager(conf)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Check that the provisioned users are all gone
|
||||||
|
users, err = a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Len(t, users, 2)
|
||||||
|
|
||||||
|
require.Equal(t, "philmanual", users[0].Name)
|
||||||
|
require.Equal(t, RoleUser, users[0].Role)
|
||||||
|
require.Equal(t, "*", users[1].Name)
|
||||||
|
|
||||||
|
grants, err = a.Grants("philuser")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(grants))
|
||||||
|
|
||||||
|
tokens, err = a.Tokens(provisionedUserID)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(tokens))
|
||||||
|
|
||||||
|
var count int
|
||||||
|
a.db.QueryRow("SELECT COUNT(*) FROM user WHERE provisioned = 1").Scan(&count)
|
||||||
|
require.Equal(t, 0, count)
|
||||||
|
a.db.QueryRow("SELECT COUNT(*) FROM user_grant WHERE provisioned = 1").Scan(&count)
|
||||||
|
require.Equal(t, 0, count)
|
||||||
|
a.db.QueryRow("SELECT COUNT(*) FROM user_token WHERE provisioned = 1").Scan(&count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
|
||||||
|
f := filepath.Join(t.TempDir(), "user.db")
|
||||||
|
conf := &Config{
|
||||||
|
Filename: f,
|
||||||
|
DefaultAccess: PermissionReadWrite,
|
||||||
|
ProvisionEnabled: true,
|
||||||
|
Users: []*User{},
|
||||||
|
Access: map[string][]*Grant{
|
||||||
|
Everyone: {
|
||||||
|
{TopicPattern: "food", Permission: PermissionRead},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Manually add user
|
||||||
|
require.Nil(t, a.AddUser("philuser", "manual", RoleUser, false))
|
||||||
|
require.Nil(t, a.AllowAccess("philuser", "stats", PermissionReadWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("philuser", "food", PermissionReadWrite))
|
||||||
|
|
||||||
|
users, err := a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Len(t, users, 2)
|
||||||
|
require.Equal(t, "philuser", users[0].Name)
|
||||||
|
require.Equal(t, RoleUser, users[0].Role)
|
||||||
|
require.False(t, users[0].Provisioned) // Manually added
|
||||||
|
|
||||||
|
grants, err := a.Grants("philuser")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(grants))
|
||||||
|
require.Equal(t, "stats", grants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[0].Permission)
|
||||||
|
require.False(t, grants[0].Provisioned) // Manually added
|
||||||
|
require.Equal(t, "food", grants[1].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
require.False(t, grants[1].Provisioned) // Manually added
|
||||||
|
|
||||||
|
grants, err = a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(grants))
|
||||||
|
require.Equal(t, "food", grants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionRead, grants[0].Permission)
|
||||||
|
require.True(t, grants[0].Provisioned) // Provisioned entry
|
||||||
|
|
||||||
|
// Re-open the DB (second app start)
|
||||||
|
require.Nil(t, a.db.Close())
|
||||||
|
conf.Users = []*User{
|
||||||
|
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
|
||||||
|
}
|
||||||
|
conf.Access = map[string][]*Grant{
|
||||||
|
"philuser": {
|
||||||
|
{TopicPattern: "stats", Permission: PermissionReadWrite},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
a, err = NewManager(conf)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Check that the user was "upgraded" to a provisioned user
|
||||||
|
users, err = a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Len(t, users, 2)
|
||||||
|
require.Equal(t, "philuser", users[0].Name)
|
||||||
|
require.Equal(t, RoleUser, users[0].Role)
|
||||||
|
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
|
||||||
|
require.True(t, users[0].Provisioned) // Updated to provisioned!
|
||||||
|
|
||||||
|
grants, err = a.Grants("philuser")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(grants))
|
||||||
|
require.Equal(t, "stats", grants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[0].Permission)
|
||||||
|
require.True(t, grants[0].Provisioned) // Updated to provisioned!
|
||||||
|
require.Equal(t, "food", grants[1].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, grants[1].Permission)
|
||||||
|
require.False(t, grants[1].Provisioned) // Manually added grants stay!
|
||||||
|
|
||||||
|
grants, err = a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, grants)
|
||||||
|
}
|
||||||
|
|
||||||
func TestToFromSQLWildcard(t *testing.T) {
|
func TestToFromSQLWildcard(t *testing.T) {
|
||||||
require.Equal(t, "up%", toSQLWildcard("up*"))
|
require.Equal(t, "up%", toSQLWildcard("up*"))
|
||||||
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
|
require.Equal(t, "up\\_%", toSQLWildcard("up_*"))
|
||||||
@@ -1162,16 +1413,16 @@ func TestMigrationFrom1(t *testing.T) {
|
|||||||
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
|
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
|
||||||
require.Equal(t, 2, len(benGrants))
|
require.Equal(t, 2, len(benGrants))
|
||||||
require.Equal(t, "secret", benGrants[0].TopicPattern)
|
require.Equal(t, "secret", benGrants[0].TopicPattern)
|
||||||
require.Equal(t, PermissionRead, benGrants[0].Allow)
|
require.Equal(t, PermissionRead, benGrants[0].Permission)
|
||||||
require.Equal(t, "stats", benGrants[1].TopicPattern)
|
require.Equal(t, "stats", benGrants[1].TopicPattern)
|
||||||
require.Equal(t, PermissionReadWrite, benGrants[1].Allow)
|
require.Equal(t, PermissionReadWrite, benGrants[1].Permission)
|
||||||
|
|
||||||
require.Equal(t, "u_everyone", everyone.ID)
|
require.Equal(t, "u_everyone", everyone.ID)
|
||||||
require.Equal(t, Everyone, everyone.Name)
|
require.Equal(t, Everyone, everyone.Name)
|
||||||
require.Equal(t, RoleAnonymous, everyone.Role)
|
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||||
require.Equal(t, 1, len(everyoneGrants))
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
|
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
|
||||||
require.Equal(t, PermissionRead, everyoneGrants[0].Allow)
|
require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrationFrom4(t *testing.T) {
|
func TestMigrationFrom4(t *testing.T) {
|
||||||
@@ -1336,7 +1587,14 @@ func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
|
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
|
||||||
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
|
conf := &Config{
|
||||||
|
Filename: filename,
|
||||||
|
StartupQueries: startupQueries,
|
||||||
|
DefaultAccess: defaultAccess,
|
||||||
|
BcryptCost: bcryptCost,
|
||||||
|
QueueWriterInterval: statsWriterInterval,
|
||||||
|
}
|
||||||
|
a, err := NewManager(conf)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|||||||
100
user/types.go
100
user/types.go
@@ -2,27 +2,27 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/stripe/stripe-go/v74"
|
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
|
"heckel.io/ntfy/v2/payments"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User is a struct that represents a user
|
// User is a struct that represents a user
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Hash string // password hash (bcrypt)
|
Hash string // Password hash (bcrypt)
|
||||||
Token string // Only set if token was used to log in
|
Token string // Only set if token was used to log in
|
||||||
Role Role
|
Role Role
|
||||||
Prefs *Prefs
|
Prefs *Prefs
|
||||||
Tier *Tier
|
Tier *Tier
|
||||||
Stats *Stats
|
Stats *Stats
|
||||||
Billing *Billing
|
Billing *Billing
|
||||||
SyncTopic string
|
SyncTopic string
|
||||||
Deleted bool
|
Provisioned bool // Whether the user was provisioned by the config file
|
||||||
|
Deleted bool // Whether the user was soft-deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
|
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
|
||||||
@@ -58,11 +58,12 @@ type Auther interface {
|
|||||||
|
|
||||||
// Token represents a user token, including expiry date
|
// Token represents a user token, including expiry date
|
||||||
type Token struct {
|
type Token struct {
|
||||||
Value string
|
Value string
|
||||||
Label string
|
Label string
|
||||||
LastAccess time.Time
|
LastAccess time.Time
|
||||||
LastOrigin netip.Addr
|
LastOrigin netip.Addr
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
|
Provisioned bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenUpdate holds information about the last access time and origin IP address of a token
|
// TokenUpdate holds information about the last access time and origin IP address of a token
|
||||||
@@ -139,8 +140,8 @@ type Stats struct {
|
|||||||
type Billing struct {
|
type Billing struct {
|
||||||
StripeCustomerID string
|
StripeCustomerID string
|
||||||
StripeSubscriptionID string
|
StripeSubscriptionID string
|
||||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
StripeSubscriptionStatus payments.SubscriptionStatus
|
||||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
StripeSubscriptionInterval payments.PriceRecurringInterval
|
||||||
StripeSubscriptionPaidUntil time.Time
|
StripeSubscriptionPaidUntil time.Time
|
||||||
StripeSubscriptionCancelAt time.Time
|
StripeSubscriptionCancelAt time.Time
|
||||||
}
|
}
|
||||||
@@ -148,7 +149,8 @@ type Billing struct {
|
|||||||
// Grant is a struct that represents an access control entry to a topic by a user
|
// Grant is a struct that represents an access control entry to a topic by a user
|
||||||
type Grant struct {
|
type Grant struct {
|
||||||
TopicPattern string // May include wildcard (*)
|
TopicPattern string // May include wildcard (*)
|
||||||
Allow Permission
|
Permission Permission
|
||||||
|
Provisioned bool // Whether the grant was provisioned by the config file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservation is a struct that represents the ownership over a topic by a user
|
// Reservation is a struct that represents the ownership over a topic by a user
|
||||||
@@ -240,48 +242,20 @@ const (
|
|||||||
everyoneID = "u_everyone"
|
everyoneID = "u_everyone"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
|
||||||
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
|
||||||
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
|
||||||
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
|
||||||
)
|
|
||||||
|
|
||||||
// AllowedRole returns true if the given role can be used for new users
|
|
||||||
func AllowedRole(role Role) bool {
|
|
||||||
return role == RoleUser || role == RoleAdmin
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedUsername returns true if the given username is valid
|
|
||||||
func AllowedUsername(username string) bool {
|
|
||||||
return allowedUsernameRegex.MatchString(username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTopic returns true if the given topic name is valid
|
|
||||||
func AllowedTopic(topic string) bool {
|
|
||||||
return allowedTopicRegex.MatchString(topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
|
||||||
func AllowedTopicPattern(topic string) bool {
|
|
||||||
return allowedTopicPatternRegex.MatchString(topic)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllowedTier returns true if the given tier name is valid
|
|
||||||
func AllowedTier(tier string) bool {
|
|
||||||
return allowedTierRegex.MatchString(tier)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error constants used by the package
|
// Error constants used by the package
|
||||||
var (
|
var (
|
||||||
ErrUnauthenticated = errors.New("unauthenticated")
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserExists = errors.New("user already exists")
|
ErrUserExists = errors.New("user already exists")
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
|
||||||
ErrTokenNotFound = errors.New("token not found")
|
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
|
||||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||||
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
|
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||||
|
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
|
||||||
|
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
|
||||||
)
|
)
|
||||||
|
|||||||
79
user/util.go
Normal file
79
user/util.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
allowedUsernameRegex = regexp.MustCompile(`^[-_.+@a-zA-Z0-9]+$`) // Does not include Everyone (*)
|
||||||
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
|
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||||
|
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||||
|
allowedTokenRegex = regexp.MustCompile(`^tk_[-_A-Za-z0-9]{29}$`) // Must be tokenLength-len(tokenPrefix)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowedRole returns true if the given role can be used for new users
|
||||||
|
func AllowedRole(role Role) bool {
|
||||||
|
return role == RoleUser || role == RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedUsername returns true if the given username is valid
|
||||||
|
func AllowedUsername(username string) bool {
|
||||||
|
return allowedUsernameRegex.MatchString(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTopic returns true if the given topic name is valid
|
||||||
|
func AllowedTopic(topic string) bool {
|
||||||
|
return allowedTopicRegex.MatchString(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||||
|
func AllowedTopicPattern(topic string) bool {
|
||||||
|
return allowedTopicPatternRegex.MatchString(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTier returns true if the given tier name is valid
|
||||||
|
func AllowedTier(tier string) bool {
|
||||||
|
return allowedTierRegex.MatchString(tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
|
||||||
|
func ValidPasswordHash(hash string, minCost int) error {
|
||||||
|
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
|
||||||
|
return ErrPasswordHashInvalid
|
||||||
|
}
|
||||||
|
cost, err := bcrypt.Cost([]byte(hash))
|
||||||
|
if err != nil { // Check if the hash is valid (length, format, etc.)
|
||||||
|
return err
|
||||||
|
} else if cost < minCost {
|
||||||
|
return ErrPasswordHashWeak
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidToken returns true if the given token matches the naming convention
|
||||||
|
func ValidToken(token string) bool {
|
||||||
|
return allowedTokenRegex.MatchString(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken generates a new token with a prefix and a fixed length
|
||||||
|
// Lowercase only to support "<topic>+<token>@<domain>" email addresses
|
||||||
|
func GenerateToken() string {
|
||||||
|
return util.RandomLowerStringPrefix(tokenPrefix, tokenLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashPassword hashes the given password using bcrypt with the configured cost
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
return hashPassword(password, DefaultUserPasswordBcryptCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashPassword(password string, cost int) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
19
util/sprig/LICENSE.txt
Normal file
19
util/sprig/LICENSE.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (C) 2013-2020 Masterminds
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
47
util/sprig/crypto.go
Normal file
47
util/sprig/crypto.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"hash/adler32"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sha512sum computes the SHA-512 hash of the input string and returns it as a hex-encoded string.
|
||||||
|
// This function can be used in templates to generate secure hashes of sensitive data.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "hello world" | sha512sum }}
|
||||||
|
func sha512sum(input string) string {
|
||||||
|
hash := sha512.Sum512([]byte(input))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha256sum computes the SHA-256 hash of the input string and returns it as a hex-encoded string.
|
||||||
|
// This is a commonly used cryptographic hash function that produces a 256-bit (32-byte) hash value.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "hello world" | sha256sum }}
|
||||||
|
func sha256sum(input string) string {
|
||||||
|
hash := sha256.Sum256([]byte(input))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// sha1sum computes the SHA-1 hash of the input string and returns it as a hex-encoded string.
|
||||||
|
// Note: SHA-1 is no longer considered secure against well-funded attackers for cryptographic purposes.
|
||||||
|
// Consider using sha256sum or sha512sum for security-critical applications.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "hello world" | sha1sum }}
|
||||||
|
func sha1sum(input string) string {
|
||||||
|
hash := sha1.Sum([]byte(input))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// adler32sum computes the Adler-32 checksum of the input string and returns it as a decimal string.
|
||||||
|
// This is a non-cryptographic hash function primarily used for error detection.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "hello world" | adler32sum }}
|
||||||
|
func adler32sum(input string) string {
|
||||||
|
hash := adler32.Checksum([]byte(input))
|
||||||
|
return fmt.Sprintf("%d", hash)
|
||||||
|
}
|
||||||
33
util/sprig/crypto_test.go
Normal file
33
util/sprig/crypto_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSha512Sum(t *testing.T) {
|
||||||
|
tpl := `{{"abc" | sha512sum}}`
|
||||||
|
if err := runt(tpl, "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSha256Sum(t *testing.T) {
|
||||||
|
tpl := `{{"abc" | sha256sum}}`
|
||||||
|
if err := runt(tpl, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSha1Sum(t *testing.T) {
|
||||||
|
tpl := `{{"abc" | sha1sum}}`
|
||||||
|
if err := runt(tpl, "a9993e364706816aba3e25717850c26c9cd0d89d"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdler32Sum(t *testing.T) {
|
||||||
|
tpl := `{{"abc" | adler32sum}}`
|
||||||
|
if err := runt(tpl, "38600999"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
240
util/sprig/date.go
Normal file
240
util/sprig/date.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// date formats a date according to the provided format string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05")
|
||||||
|
// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)
|
||||||
|
//
|
||||||
|
// If date is not one of the recognized types, the current time is used.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | date "2006-01-02" }}
|
||||||
|
func date(fmt string, date any) string {
|
||||||
|
return dateInZone(fmt, date, "Local")
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlDate formats a date in HTML5 date format (YYYY-MM-DD).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)
|
||||||
|
//
|
||||||
|
// If date is not one of the recognized types, the current time is used.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | htmlDate }}
|
||||||
|
func htmlDate(date any) string {
|
||||||
|
return dateInZone("2006-01-02", date, "Local")
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlDateInZone formats a date in HTML5 date format (YYYY-MM-DD) in the specified timezone.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)
|
||||||
|
// - zone: Timezone name (e.g., "UTC", "America/New_York")
|
||||||
|
//
|
||||||
|
// If date is not one of the recognized types, the current time is used.
|
||||||
|
// If the timezone is invalid, UTC is used.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | htmlDateInZone "UTC" }}
|
||||||
|
func htmlDateInZone(date any, zone string) string {
|
||||||
|
return dateInZone("2006-01-02", date, zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateInZone formats a date according to the provided format string in the specified timezone.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A Go time format string (e.g., "2006-01-02 15:04:05")
|
||||||
|
// - date: Can be a time.Time, *time.Time, or int/int32/int64 (seconds since UNIX epoch)
|
||||||
|
// - zone: Timezone name (e.g., "UTC", "America/New_York")
|
||||||
|
//
|
||||||
|
// If date is not one of the recognized types, the current time is used.
|
||||||
|
// If the timezone is invalid, UTC is used.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | dateInZone "2006-01-02 15:04:05" "UTC" }}
|
||||||
|
func dateInZone(fmt string, date any, zone string) string {
|
||||||
|
var t time.Time
|
||||||
|
switch date := date.(type) {
|
||||||
|
default:
|
||||||
|
t = time.Now()
|
||||||
|
case time.Time:
|
||||||
|
t = date
|
||||||
|
case *time.Time:
|
||||||
|
t = *date
|
||||||
|
case int64:
|
||||||
|
t = time.Unix(date, 0)
|
||||||
|
case int:
|
||||||
|
t = time.Unix(int64(date), 0)
|
||||||
|
case int32:
|
||||||
|
t = time.Unix(int64(date), 0)
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(zone)
|
||||||
|
if err != nil {
|
||||||
|
loc, _ = time.LoadLocation("UTC")
|
||||||
|
}
|
||||||
|
return t.In(loc).Format(fmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateModify modifies a date by adding a duration and returns the resulting time.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s")
|
||||||
|
// - date: The time.Time to modify
|
||||||
|
//
|
||||||
|
// If the duration string is invalid, the original date is returned.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | dateModify "-24h" }}
|
||||||
|
func dateModify(fmt string, date time.Time) time.Time {
|
||||||
|
d, err := time.ParseDuration(fmt)
|
||||||
|
if err != nil {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
return date.Add(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustDateModify modifies a date by adding a duration and returns the resulting time or an error.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A duration string (e.g., "24h", "-12h30m", "1h15m30s")
|
||||||
|
// - date: The time.Time to modify
|
||||||
|
//
|
||||||
|
// Unlike dateModify, this function returns an error if the duration string is invalid.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | mustDateModify "24h" }}
|
||||||
|
func mustDateModify(fmt string, date time.Time) (time.Time, error) {
|
||||||
|
d, err := time.ParseDuration(fmt)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return date.Add(d), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateAgo returns a string representing the time elapsed since the given date.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - date: Can be a time.Time, int, or int64 (seconds since UNIX epoch)
|
||||||
|
//
|
||||||
|
// If date is not one of the recognized types, the current time is used.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" | dateAgo }}
|
||||||
|
func dateAgo(date any) string {
|
||||||
|
var t time.Time
|
||||||
|
switch date := date.(type) {
|
||||||
|
default:
|
||||||
|
t = time.Now()
|
||||||
|
case time.Time:
|
||||||
|
t = date
|
||||||
|
case int64:
|
||||||
|
t = time.Unix(date, 0)
|
||||||
|
case int:
|
||||||
|
t = time.Unix(int64(date), 0)
|
||||||
|
}
|
||||||
|
return time.Since(t).Round(time.Second).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// duration converts seconds to a duration string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sec: Can be a string (parsed as int64), or int64 representing seconds
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ 3600 | duration }} -> "1h0m0s"
|
||||||
|
func duration(sec any) string {
|
||||||
|
var n int64
|
||||||
|
switch value := sec.(type) {
|
||||||
|
default:
|
||||||
|
n = 0
|
||||||
|
case string:
|
||||||
|
n, _ = strconv.ParseInt(value, 10, 64)
|
||||||
|
case int64:
|
||||||
|
n = value
|
||||||
|
}
|
||||||
|
return (time.Duration(n) * time.Second).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationRound formats a duration in a human-readable rounded format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - duration: Can be a string (parsed as duration), int64 (nanoseconds),
|
||||||
|
// or time.Time (time since that moment)
|
||||||
|
//
|
||||||
|
// Returns a string with the largest appropriate unit (y, mo, d, h, m, s).
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ 3600 | duration | durationRound }} -> "1h"
|
||||||
|
func durationRound(duration any) string {
|
||||||
|
var d time.Duration
|
||||||
|
switch duration := duration.(type) {
|
||||||
|
default:
|
||||||
|
d = 0
|
||||||
|
case string:
|
||||||
|
d, _ = time.ParseDuration(duration)
|
||||||
|
case int64:
|
||||||
|
d = time.Duration(duration)
|
||||||
|
case time.Time:
|
||||||
|
d = time.Since(duration)
|
||||||
|
}
|
||||||
|
u := uint64(math.Abs(float64(d)))
|
||||||
|
var (
|
||||||
|
year = uint64(time.Hour) * 24 * 365
|
||||||
|
month = uint64(time.Hour) * 24 * 30
|
||||||
|
day = uint64(time.Hour) * 24
|
||||||
|
hour = uint64(time.Hour)
|
||||||
|
minute = uint64(time.Minute)
|
||||||
|
second = uint64(time.Second)
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case u > year:
|
||||||
|
return strconv.FormatUint(u/year, 10) + "y"
|
||||||
|
case u > month:
|
||||||
|
return strconv.FormatUint(u/month, 10) + "mo"
|
||||||
|
case u > day:
|
||||||
|
return strconv.FormatUint(u/day, 10) + "d"
|
||||||
|
case u > hour:
|
||||||
|
return strconv.FormatUint(u/hour, 10) + "h"
|
||||||
|
case u > minute:
|
||||||
|
return strconv.FormatUint(u/minute, 10) + "m"
|
||||||
|
case u > second:
|
||||||
|
return strconv.FormatUint(u/second, 10) + "s"
|
||||||
|
}
|
||||||
|
return "0s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDate parses a string into a time.Time using the specified format.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A Go time format string (e.g., "2006-01-02")
|
||||||
|
// - str: The date string to parse
|
||||||
|
//
|
||||||
|
// If parsing fails, returns a zero time.Time.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ "2023-01-01" | toDate "2006-01-02" }}
|
||||||
|
func toDate(fmt, str string) time.Time {
|
||||||
|
t, _ := time.ParseInLocation(fmt, str, time.Local)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustToDate parses a string into a time.Time using the specified format or returns an error.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - fmt: A Go time format string (e.g., "2006-01-02")
|
||||||
|
// - str: The date string to parse
|
||||||
|
//
|
||||||
|
// Unlike toDate, this function returns an error if parsing fails.
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ mustToDate "2006-01-02" "2023-01-01" }}
|
||||||
|
func mustToDate(fmt, str string) (time.Time, error) {
|
||||||
|
return time.ParseInLocation(fmt, str, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unixEpoch returns the Unix timestamp (seconds since January 1, 1970 UTC) for the given time.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - date: A time.Time value
|
||||||
|
//
|
||||||
|
// Example usage in templates: {{ now | unixEpoch }}
|
||||||
|
func unixEpoch(date time.Time) string {
|
||||||
|
return strconv.FormatInt(date.Unix(), 10)
|
||||||
|
}
|
||||||
123
util/sprig/date_test.go
Normal file
123
util/sprig/date_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHtmlDate(t *testing.T) {
|
||||||
|
t.Skip()
|
||||||
|
tpl := `{{ htmlDate 0}}`
|
||||||
|
if err := runt(tpl, "1970-01-01"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgo(t *testing.T) {
|
||||||
|
tpl := "{{ ago .Time }}"
|
||||||
|
if err := runtv(tpl, "2m5s", map[string]any{"Time": time.Now().Add(-125 * time.Second)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtv(tpl, "2h34m17s", map[string]any{"Time": time.Now().Add(-(2*3600 + 34*60 + 17) * time.Second)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runtv(tpl, "-5s", map[string]any{"Time": time.Now().Add(5 * time.Second)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToDate(t *testing.T) {
|
||||||
|
tpl := `{{toDate "2006-01-02" "2017-12-31" | date "02/01/2006"}}`
|
||||||
|
if err := runt(tpl, "31/12/2017"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnixEpoch(t *testing.T) {
|
||||||
|
tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT")
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl := `{{unixEpoch .Time}}`
|
||||||
|
|
||||||
|
if err = runtv(tpl, "1560458379", map[string]any{"Time": tm}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDateInZone(t *testing.T) {
|
||||||
|
tm, err := time.Parse("02 Jan 06 15:04:05 MST", "13 Jun 19 20:39:39 GMT")
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl := `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "UTC" }}`
|
||||||
|
|
||||||
|
// Test time.Time input
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test pointer to time.Time input
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": &tm}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test no time input. This should be close enough to time.Now() we can test
|
||||||
|
loc, _ := time.LoadLocation("UTC")
|
||||||
|
if err = runtv(tpl, time.Now().In(loc).Format("02 Jan 06 15:04 -0700"), map[string]any{"Time": ""}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test unix timestamp as int64
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int64(1560458379)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test unix timestamp as int32
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int32(1560458379)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test unix timestamp as int
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": int(1560458379)}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case of invalid timezone
|
||||||
|
tpl = `{{ dateInZone "02 Jan 06 15:04 -0700" .Time "foobar" }}`
|
||||||
|
if err = runtv(tpl, "13 Jun 19 20:39 +0000", map[string]any{"Time": tm}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuration(t *testing.T) {
|
||||||
|
tpl := "{{ duration .Secs }}"
|
||||||
|
if err := runtv(tpl, "1m1s", map[string]any{"Secs": "61"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := runtv(tpl, "1h0m0s", map[string]any{"Secs": "3600"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
// 1d2h3m4s but go is opinionated
|
||||||
|
if err := runtv(tpl, "26h3m4s", map[string]any{"Secs": "93784"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurationRound(t *testing.T) {
|
||||||
|
tpl := "{{ durationRound .Time }}"
|
||||||
|
if err := runtv(tpl, "2h", map[string]any{"Time": "2h5s"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := runtv(tpl, "1d", map[string]any{"Time": "24h5s"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := runtv(tpl, "3mo", map[string]any{"Time": "2400h5s"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if err := runtv(tpl, "1m", map[string]any{"Time": "-1m1s"}); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
268
util/sprig/defaults.go
Normal file
268
util/sprig/defaults.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultValue checks whether `given` is set, and returns default if not set.
|
||||||
|
//
|
||||||
|
// This returns `d` if `given` appears not to be set, and `given` otherwise.
|
||||||
|
//
|
||||||
|
// For numeric types 0 is unset.
|
||||||
|
// For strings, maps, arrays, and slices, len() = 0 is considered unset.
|
||||||
|
// For bool, false is unset.
|
||||||
|
// Structs are never considered unset.
|
||||||
|
//
|
||||||
|
// For everything else, including pointers, a nil value is unset.
|
||||||
|
func defaultValue(d any, given ...any) any {
|
||||||
|
if empty(given) || empty(given[0]) {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return given[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// empty returns true if the given value has the zero value for its type.
|
||||||
|
// This is a helper function used by defaultValue, coalesce, all, and anyNonEmpty.
|
||||||
|
//
|
||||||
|
// The following values are considered empty:
|
||||||
|
// - Invalid values
|
||||||
|
// - nil values
|
||||||
|
// - Zero-length arrays, slices, maps, and strings
|
||||||
|
// - Boolean false
|
||||||
|
// - Zero for all numeric types
|
||||||
|
// - Structs are never considered empty
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - given: The value to check for emptiness
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the value is considered empty, false otherwise
|
||||||
|
func empty(given any) bool {
|
||||||
|
g := reflect.ValueOf(given)
|
||||||
|
if !g.IsValid() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Basically adapted from text/template.isTrue
|
||||||
|
switch g.Kind() {
|
||||||
|
default:
|
||||||
|
return g.IsNil()
|
||||||
|
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
|
||||||
|
return g.Len() == 0
|
||||||
|
case reflect.Bool:
|
||||||
|
return !g.Bool()
|
||||||
|
case reflect.Complex64, reflect.Complex128:
|
||||||
|
return g.Complex() == 0
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return g.Int() == 0
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
return g.Uint() == 0
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return g.Float() == 0
|
||||||
|
case reflect.Struct:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// coalesce returns the first non-empty value from a list of values.
|
||||||
|
// If all values are empty, it returns nil.
|
||||||
|
//
|
||||||
|
// This is useful for providing a series of fallback values.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: A variadic list of values to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The first non-empty value, or nil if all values are empty
|
||||||
|
func coalesce(v ...any) any {
|
||||||
|
for _, val := range v {
|
||||||
|
if !empty(val) {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// all checks if all values in a list are non-empty.
|
||||||
|
// Returns true if every value in the list is non-empty.
|
||||||
|
// If the list is empty, returns true (vacuously true).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: A variadic list of values to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if all values are non-empty, false otherwise
|
||||||
|
func all(v ...any) bool {
|
||||||
|
for _, val := range v {
|
||||||
|
if empty(val) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyNonEmpty checks if at least one value in a list is non-empty.
|
||||||
|
// Returns true if any value in the list is non-empty.
|
||||||
|
// If the list is empty, returns false.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: A variadic list of values to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if at least one value is non-empty, false otherwise
|
||||||
|
func anyNonEmpty(v ...any) bool {
|
||||||
|
for _, val := range v {
|
||||||
|
if !empty(val) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromJSON decodes a JSON string into a structured value.
|
||||||
|
// This function ignores any errors that occur during decoding.
|
||||||
|
// If the JSON is invalid, it returns nil.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The JSON string to decode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The decoded value, or nil if decoding failed
|
||||||
|
func fromJSON(v string) any {
|
||||||
|
output, _ := mustFromJSON(v)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustFromJSON decodes a JSON string into a structured value.
|
||||||
|
// Unlike fromJSON, this function returns any errors that occur during decoding.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The JSON string to decode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The decoded value
|
||||||
|
// - error: Any error that occurred during decoding
|
||||||
|
func mustFromJSON(v string) (any, error) {
|
||||||
|
var output any
|
||||||
|
err := json.Unmarshal([]byte(v), &output)
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// toJSON encodes a value into a JSON string.
|
||||||
|
// This function ignores any errors that occur during encoding.
|
||||||
|
// If the value cannot be encoded, it returns an empty string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The JSON string representation of the value
|
||||||
|
func toJSON(v any) string {
|
||||||
|
output, _ := json.Marshal(v)
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustToJSON encodes a value into a JSON string.
|
||||||
|
// Unlike toJSON, this function returns any errors that occur during encoding.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The JSON string representation of the value
|
||||||
|
// - error: Any error that occurred during encoding
|
||||||
|
func mustToJSON(v any) (string, error) {
|
||||||
|
output, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPrettyJSON encodes a value into a pretty (indented) JSON string.
|
||||||
|
// This function ignores any errors that occur during encoding.
|
||||||
|
// If the value cannot be encoded, it returns an empty string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The indented JSON string representation of the value
|
||||||
|
func toPrettyJSON(v any) string {
|
||||||
|
output, _ := json.MarshalIndent(v, "", " ")
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustToPrettyJSON encodes a value into a pretty (indented) JSON string.
|
||||||
|
// Unlike toPrettyJSON, this function returns any errors that occur during encoding.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The indented JSON string representation of the value
|
||||||
|
// - error: Any error that occurred during encoding
|
||||||
|
func mustToPrettyJSON(v any) (string, error) {
|
||||||
|
output, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRawJSON encodes a value into a JSON string with no escaping of HTML characters.
|
||||||
|
// This function panics if an error occurs during encoding.
|
||||||
|
// Unlike toJSON, HTML characters like <, >, and & are not escaped.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The JSON string representation of the value without HTML escaping
|
||||||
|
func toRawJSON(v any) string {
|
||||||
|
output, err := mustToRawJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustToRawJSON encodes a value into a JSON string with no escaping of HTML characters.
|
||||||
|
// Unlike toRawJSON, this function returns any errors that occur during encoding.
|
||||||
|
// HTML characters like <, >, and & are not escaped in the output.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to encode to JSON
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The JSON string representation of the value without HTML escaping
|
||||||
|
// - error: Any error that occurred during encoding
|
||||||
|
func mustToRawJSON(v any) (string, error) {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
enc := json.NewEncoder(buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
if err := enc.Encode(&v); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(buf.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ternary implements a conditional (ternary) operator.
|
||||||
|
// It returns the first value if the condition is true, otherwise returns the second value.
|
||||||
|
// This is similar to the ?: operator in many programming languages.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - vt: The value to return if the condition is true
|
||||||
|
// - vf: The value to return if the condition is false
|
||||||
|
// - v: The boolean condition to evaluate
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: Either vt or vf depending on the value of v
|
||||||
|
func ternary(vt any, vf any, v bool) any {
|
||||||
|
if v {
|
||||||
|
return vt
|
||||||
|
}
|
||||||
|
return vf
|
||||||
|
}
|
||||||
196
util/sprig/defaults_test.go
Normal file
196
util/sprig/defaults_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefault(t *testing.T) {
|
||||||
|
tpl := `{{"" | default "foo"}}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{default "foo" 234}}`
|
||||||
|
if err := runt(tpl, "234"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{default "foo" 2.34}}`
|
||||||
|
if err := runt(tpl, "2.34"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ .Nothing | default "123" }}`
|
||||||
|
if err := runt(tpl, "123"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ default "123" }}`
|
||||||
|
if err := runt(tpl, "123"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmpty(t *testing.T) {
|
||||||
|
tpl := `{{if empty 1}}1{{else}}0{{end}}`
|
||||||
|
if err := runt(tpl, "0"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{if empty 0}}1{{else}}0{{end}}`
|
||||||
|
if err := runt(tpl, "1"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{if empty ""}}1{{else}}0{{end}}`
|
||||||
|
if err := runt(tpl, "1"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{if empty 0.0}}1{{else}}0{{end}}`
|
||||||
|
if err := runt(tpl, "1"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{if empty false}}1{{else}}0{{end}}`
|
||||||
|
if err := runt(tpl, "1"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dict := map[string]any{"top": map[string]any{}}
|
||||||
|
tpl = `{{if empty .top.NoSuchThing}}1{{else}}0{{end}}`
|
||||||
|
if err := runtv(tpl, "1", dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{if empty .bottom.NoSuchThing}}1{{else}}0{{end}}`
|
||||||
|
if err := runtv(tpl, "1", dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoalesce(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ coalesce 1 }}`: "1",
|
||||||
|
`{{ coalesce "" 0 nil 2 }}`: "2",
|
||||||
|
`{{ $two := 2 }}{{ coalesce "" 0 nil $two }}`: "2",
|
||||||
|
`{{ $two := 2 }}{{ coalesce "" $two 0 0 0 }}`: "2",
|
||||||
|
`{{ $two := 2 }}{{ coalesce "" $two 3 4 5 }}`: "2",
|
||||||
|
`{{ coalesce }}`: "<no value>",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
|
||||||
|
dict := map[string]any{"top": map[string]any{}}
|
||||||
|
tpl := `{{ coalesce .top.NoSuchThing .bottom .bottom.dollar "airplane"}}`
|
||||||
|
if err := runtv(tpl, "airplane", dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAll(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ all 1 }}`: "true",
|
||||||
|
`{{ all "" 0 nil 2 }}`: "false",
|
||||||
|
`{{ $two := 2 }}{{ all "" 0 nil $two }}`: "false",
|
||||||
|
`{{ $two := 2 }}{{ all "" $two 0 0 0 }}`: "false",
|
||||||
|
`{{ $two := 2 }}{{ all "" $two 3 4 5 }}`: "false",
|
||||||
|
`{{ all }}`: "true",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
|
||||||
|
dict := map[string]any{"top": map[string]any{}}
|
||||||
|
tpl := `{{ all .top.NoSuchThing .bottom .bottom.dollar "airplane"}}`
|
||||||
|
if err := runtv(tpl, "false", dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAny(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ any 1 }}`: "true",
|
||||||
|
`{{ any "" 0 nil 2 }}`: "true",
|
||||||
|
`{{ $two := 2 }}{{ any "" 0 nil $two }}`: "true",
|
||||||
|
`{{ $two := 2 }}{{ any "" $two 3 4 5 }}`: "true",
|
||||||
|
`{{ $zero := 0 }}{{ any "" $zero 0 0 0 }}`: "false",
|
||||||
|
`{{ any }}`: "false",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
|
||||||
|
dict := map[string]any{"top": map[string]any{}}
|
||||||
|
tpl := `{{ any .top.NoSuchThing .bottom .bottom.dollar "airplane"}}`
|
||||||
|
if err := runtv(tpl, "true", dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromJSON(t *testing.T) {
|
||||||
|
dict := map[string]any{"Input": `{"foo": 55}`}
|
||||||
|
|
||||||
|
tpl := `{{.Input | fromJSON}}`
|
||||||
|
expected := `map[foo:55]`
|
||||||
|
if err := runtv(tpl, expected, dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{(.Input | fromJSON).foo}}`
|
||||||
|
expected = `55`
|
||||||
|
if err := runtv(tpl, expected, dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToJSON(t *testing.T) {
|
||||||
|
dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}}
|
||||||
|
|
||||||
|
tpl := `{{.Top | toJSON}}`
|
||||||
|
expected := `{"bool":true,"number":42,"string":"test"}`
|
||||||
|
if err := runtv(tpl, expected, dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToPrettyJSON(t *testing.T) {
|
||||||
|
dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42}}
|
||||||
|
tpl := `{{.Top | toPrettyJSON}}`
|
||||||
|
expected := `{
|
||||||
|
"bool": true,
|
||||||
|
"number": 42,
|
||||||
|
"string": "test"
|
||||||
|
}`
|
||||||
|
if err := runtv(tpl, expected, dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToRawJSON(t *testing.T) {
|
||||||
|
dict := map[string]any{"Top": map[string]any{"bool": true, "string": "test", "number": 42, "html": "<HEAD>"}}
|
||||||
|
tpl := `{{.Top | toRawJSON}}`
|
||||||
|
expected := `{"bool":true,"html":"<HEAD>","number":42,"string":"test"}`
|
||||||
|
|
||||||
|
if err := runtv(tpl, expected, dict); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTernary(t *testing.T) {
|
||||||
|
tpl := `{{true | ternary "foo" "bar"}}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ternary "foo" "bar" true}}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{false | ternary "foo" "bar"}}`
|
||||||
|
if err := runt(tpl, "bar"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ternary "foo" "bar" false}}`
|
||||||
|
if err := runt(tpl, "bar"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
233
util/sprig/dict.go
Normal file
233
util/sprig/dict.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
// get retrieves a value from a map by its key.
|
||||||
|
// If the key exists, returns the corresponding value.
|
||||||
|
// If the key doesn't exist, returns an empty string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - d: The map to retrieve the value from
|
||||||
|
// - key: The key to look up
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The value associated with the key, or an empty string if not found
|
||||||
|
func get(d map[string]any, key string) any {
|
||||||
|
if val, ok := d[key]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// set adds or updates a key-value pair in a map.
|
||||||
|
// Modifies the map in place and returns the modified map.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - d: The map to modify
|
||||||
|
// - key: The key to set
|
||||||
|
// - value: The value to associate with the key
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]any: The modified map (same instance as the input map)
|
||||||
|
func set(d map[string]any, key string, value any) map[string]any {
|
||||||
|
d[key] = value
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// unset removes a key-value pair from a map.
|
||||||
|
// If the key doesn't exist, the map remains unchanged.
|
||||||
|
// Modifies the map in place and returns the modified map.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - d: The map to modify
|
||||||
|
// - key: The key to remove
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]any: The modified map (same instance as the input map)
|
||||||
|
func unset(d map[string]any, key string) map[string]any {
|
||||||
|
delete(d, key)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasKey checks if a key exists in a map.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - d: The map to check
|
||||||
|
// - key: The key to look for
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the key exists in the map, false otherwise
|
||||||
|
func hasKey(d map[string]any, key string) bool {
|
||||||
|
_, ok := d[key]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluck extracts values for a specific key from multiple maps.
|
||||||
|
// Only includes values from maps where the key exists.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - key: The key to extract values for
|
||||||
|
// - d: A variadic list of maps to extract values from
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []any: A slice containing all values associated with the key across all maps
|
||||||
|
func pluck(key string, d ...map[string]any) []any {
|
||||||
|
var res []any
|
||||||
|
for _, dict := range d {
|
||||||
|
if val, ok := dict[key]; ok {
|
||||||
|
res = append(res, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys collects all keys from one or more maps.
|
||||||
|
// The returned slice may contain duplicate keys if multiple maps contain the same key.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - dicts: A variadic list of maps to collect keys from
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing all keys from all provided maps
|
||||||
|
func keys(dicts ...map[string]any) []string {
|
||||||
|
var k []string
|
||||||
|
for _, dict := range dicts {
|
||||||
|
for key := range dict {
|
||||||
|
k = append(k, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick creates a new map containing only the specified keys from the original map.
|
||||||
|
// If a key doesn't exist in the original map, it won't be included in the result.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - dict: The source map
|
||||||
|
// - keys: A variadic list of keys to include in the result
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]any: A new map containing only the specified keys and their values
|
||||||
|
func pick(dict map[string]any, keys ...string) map[string]any {
|
||||||
|
res := map[string]any{}
|
||||||
|
for _, k := range keys {
|
||||||
|
if v, ok := dict[k]; ok {
|
||||||
|
res[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// omit creates a new map excluding the specified keys from the original map.
|
||||||
|
// The original map remains unchanged.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - dict: The source map
|
||||||
|
// - keys: A variadic list of keys to exclude from the result
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]any: A new map containing all key-value pairs except those specified
|
||||||
|
func omit(dict map[string]any, keys ...string) map[string]any {
|
||||||
|
res := map[string]any{}
|
||||||
|
omit := make(map[string]bool, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
omit[k] = true
|
||||||
|
}
|
||||||
|
for k, v := range dict {
|
||||||
|
if _, ok := omit[k]; !ok {
|
||||||
|
res[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// dict creates a new map from a list of key-value pairs.
|
||||||
|
// The arguments are treated as key-value pairs, where even-indexed arguments are keys
|
||||||
|
// and odd-indexed arguments are values.
|
||||||
|
// If there's an odd number of arguments, the last key will be assigned an empty string value.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: A variadic list of alternating keys and values
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]any: A new map containing the specified key-value pairs
|
||||||
|
func dict(v ...any) map[string]any {
|
||||||
|
dict := map[string]any{}
|
||||||
|
lenv := len(v)
|
||||||
|
for i := 0; i < lenv; i += 2 {
|
||||||
|
key := strval(v[i])
|
||||||
|
if i+1 >= lenv {
|
||||||
|
dict[key] = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dict[key] = v[i+1]
|
||||||
|
}
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
// values collects all values from a map into a slice.
|
||||||
|
// The order of values in the resulting slice is not guaranteed.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - dict: The map to collect values from
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []any: A slice containing all values from the map
|
||||||
|
func values(dict map[string]any) []any {
|
||||||
|
var values []any
|
||||||
|
for _, value := range dict {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// dig safely accesses nested values in maps using a sequence of keys.
|
||||||
|
// If any key in the path doesn't exist, it returns the default value.
|
||||||
|
// The function expects at least 3 arguments: one or more keys, a default value, and a map.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ps: A variadic list where:
|
||||||
|
// - The first N-2 arguments are string keys forming the path
|
||||||
|
// - The second-to-last argument is the default value to return if the path doesn't exist
|
||||||
|
// - The last argument is the map to traverse
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The value found at the specified path, or the default value if not found
|
||||||
|
// - error: Any error that occurred during traversal
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If fewer than 3 arguments are provided
|
||||||
|
func dig(ps ...any) (any, error) {
|
||||||
|
if len(ps) < 3 {
|
||||||
|
panic("dig needs at least three arguments")
|
||||||
|
}
|
||||||
|
dict := ps[len(ps)-1].(map[string]any)
|
||||||
|
def := ps[len(ps)-2]
|
||||||
|
ks := make([]string, len(ps)-2)
|
||||||
|
for i := 0; i < len(ks); i++ {
|
||||||
|
ks[i] = ps[i].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
return digFromDict(dict, def, ks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// digFromDict is a helper function for dig that recursively traverses a map using a sequence of keys.
|
||||||
|
// If any key in the path doesn't exist, it returns the default value.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - dict: The map to traverse
|
||||||
|
// - d: The default value to return if the path doesn't exist
|
||||||
|
// - ks: A slice of string keys forming the path to traverse
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - any: The value found at the specified path, or the default value if not found
|
||||||
|
// - error: Any error that occurred during traversal
|
||||||
|
func digFromDict(dict map[string]any, d any, ks []string) (any, error) {
|
||||||
|
k, ns := ks[0], ks[1:]
|
||||||
|
step, has := dict[k]
|
||||||
|
if !has {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
if len(ns) == 0 {
|
||||||
|
return step, nil
|
||||||
|
}
|
||||||
|
return digFromDict(step.(map[string]any), d, ns)
|
||||||
|
}
|
||||||
166
util/sprig/dict_test.go
Normal file
166
util/sprig/dict_test.go
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDict(t *testing.T) {
|
||||||
|
tpl := `{{$d := dict 1 2 "three" "four" 5}}{{range $k, $v := $d}}{{$k}}{{$v}}{{end}}`
|
||||||
|
out, err := runRaw(tpl, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if len(out) != 12 {
|
||||||
|
t.Errorf("Expected length 12, got %d", len(out))
|
||||||
|
}
|
||||||
|
// dict does not guarantee ordering because it is backed by a map.
|
||||||
|
if !strings.Contains(out, "12") {
|
||||||
|
t.Error("Expected grouping 12")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "threefour") {
|
||||||
|
t.Error("Expected grouping threefour")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "5") {
|
||||||
|
t.Error("Expected 5")
|
||||||
|
}
|
||||||
|
tpl = `{{$t := dict "I" "shot" "the" "albatross"}}{{$t.the}} {{$t.I}}`
|
||||||
|
if err := runt(tpl, "albatross shot"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnset(t *testing.T) {
|
||||||
|
tpl := `{{- $d := dict "one" 1 "two" 222222 -}}
|
||||||
|
{{- $_ := unset $d "two" -}}
|
||||||
|
{{- range $k, $v := $d}}{{$k}}{{$v}}{{- end -}}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect := "one1"
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestHasKey(t *testing.T) {
|
||||||
|
tpl := `{{- $d := dict "one" 1 "two" 222222 -}}
|
||||||
|
{{- if hasKey $d "one" -}}1{{- end -}}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect := "1"
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluck(t *testing.T) {
|
||||||
|
tpl := `
|
||||||
|
{{- $d := dict "one" 1 "two" 222222 -}}
|
||||||
|
{{- $d2 := dict "one" 1 "two" 33333 -}}
|
||||||
|
{{- $d3 := dict "one" 1 -}}
|
||||||
|
{{- $d4 := dict "one" 1 "two" 4444 -}}
|
||||||
|
{{- pluck "two" $d $d2 $d3 $d4 -}}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect := "[222222 33333 4444]"
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeys(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ dict "foo" 1 "bar" 2 | keys | sortAlpha }}`: "[bar foo]",
|
||||||
|
`{{ dict | keys }}`: "[]",
|
||||||
|
`{{ keys (dict "foo" 1) (dict "bar" 2) (dict "bar" 3) | uniq | sortAlpha }}`: "[bar foo]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPick(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" | len -}}`: "1",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "two" -}}`: "map[two:222222]",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" | len -}}`: "2",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ pick $d "one" "two" "three" | len -}}`: "2",
|
||||||
|
`{{- $d := dict }}{{ pick $d "two" | len -}}`: "0",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestOmit(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" | len -}}`: "1",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" -}}`: "map[two:222222]",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "one" "two" | len -}}`: "0",
|
||||||
|
`{{- $d := dict "one" 1 "two" 222222 }}{{ omit $d "two" "three" | len -}}`: "1",
|
||||||
|
`{{- $d := dict }}{{ omit $d "two" | len -}}`: "0",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{- $d := dict "one" 1 }}{{ get $d "one" -}}`: "1",
|
||||||
|
`{{- $d := dict "one" 1 "two" "2" }}{{ get $d "two" -}}`: "2",
|
||||||
|
`{{- $d := dict }}{{ get $d "two" -}}`: "",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSet(t *testing.T) {
|
||||||
|
tpl := `{{- $d := dict "one" 1 "two" 222222 -}}
|
||||||
|
{{- $_ := set $d "two" 2 -}}
|
||||||
|
{{- $_ := set $d "three" 3 -}}
|
||||||
|
{{- if hasKey $d "one" -}}{{$d.one}}{{- end -}}
|
||||||
|
{{- if hasKey $d "two" -}}{{$d.two}}{{- end -}}
|
||||||
|
{{- if hasKey $d "three" -}}{{$d.three}}{{- end -}}
|
||||||
|
`
|
||||||
|
|
||||||
|
expect := "123"
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValues(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{- $d := dict "a" 1 "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "1,2",
|
||||||
|
`{{- $d := dict "a" "first" "b" 2 }}{{ values $d | sortAlpha | join "," }}`: "2,first",
|
||||||
|
}
|
||||||
|
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDig(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "c" "" $d }}`: "1",
|
||||||
|
`{{- $d := dict "a" (dict "b" (dict "c" 1)) }}{{ dig "a" "b" "z" "2" $d }}`: "2",
|
||||||
|
`{{ dict "a" 1 | dig "a" "" }}`: "1",
|
||||||
|
`{{ dict "a" 1 | dig "z" "2" }}`: "2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
util/sprig/doc.go
Normal file
19
util/sprig/doc.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
Package sprig provides template functions for Go.
|
||||||
|
|
||||||
|
This package contains a number of utility functions for working with data
|
||||||
|
inside of Go `html/template` and `text/template` files.
|
||||||
|
|
||||||
|
To add these functions, use the `template.Funcs()` method:
|
||||||
|
|
||||||
|
t := template.New("foo").Funcs(sprig.FuncMap())
|
||||||
|
|
||||||
|
Note that you should add the function map before you parse any template files.
|
||||||
|
|
||||||
|
In several cases, Sprig reverses the order of arguments from the way they
|
||||||
|
appear in the standard library. This is to make it easier to pipe
|
||||||
|
arguments into functions.
|
||||||
|
|
||||||
|
See http://masterminds.github.io/sprig/ for more detailed documentation on each of the available functions.
|
||||||
|
*/
|
||||||
|
package sprig
|
||||||
25
util/sprig/example_test.go
Normal file
25
util/sprig/example_test.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
// Set up variables and template.
|
||||||
|
vars := map[string]any{"Name": " John Jacob Jingleheimer Schmidt "}
|
||||||
|
tpl := `Hello {{.Name | trim | lower}}`
|
||||||
|
|
||||||
|
// Get the Sprig function map.
|
||||||
|
fmap := TxtFuncMap()
|
||||||
|
t := template.Must(template.New("test").Funcs(fmap).Parse(tpl))
|
||||||
|
|
||||||
|
err := t.Execute(os.Stdout, vars)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error during template execution: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Output:
|
||||||
|
// Hello john jacob jingleheimer schmidt
|
||||||
|
}
|
||||||
8
util/sprig/flow_control.go
Normal file
8
util/sprig/flow_control.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// fail is a function that always returns an error with the given message.
|
||||||
|
func fail(msg string) (string, error) {
|
||||||
|
return "", errors.New(msg)
|
||||||
|
}
|
||||||
16
util/sprig/flow_control_test.go
Normal file
16
util/sprig/flow_control_test.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFail(t *testing.T) {
|
||||||
|
const msg = "This is an error!"
|
||||||
|
tpl := fmt.Sprintf(`{{fail "%s"}}`, msg)
|
||||||
|
_, err := runRaw(tpl, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), msg)
|
||||||
|
}
|
||||||
214
util/sprig/functions.go
Normal file
214
util/sprig/functions.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
|
||||||
|
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
|
||||||
|
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
|
||||||
|
)
|
||||||
|
|
||||||
|
// TxtFuncMap produces the function map.
|
||||||
|
//
|
||||||
|
// Use this to pass the functions into the template engine:
|
||||||
|
//
|
||||||
|
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
|
||||||
|
//
|
||||||
|
// TxtFuncMap returns a 'text/template'.FuncMap
|
||||||
|
func TxtFuncMap() template.FuncMap {
|
||||||
|
return map[string]any{
|
||||||
|
// Date functions
|
||||||
|
"ago": dateAgo,
|
||||||
|
"date": date,
|
||||||
|
"dateInZone": dateInZone,
|
||||||
|
"dateModify": dateModify,
|
||||||
|
"duration": duration,
|
||||||
|
"durationRound": durationRound,
|
||||||
|
"htmlDate": htmlDate,
|
||||||
|
"htmlDateInZone": htmlDateInZone,
|
||||||
|
"mustDateModify": mustDateModify,
|
||||||
|
"mustToDate": mustToDate,
|
||||||
|
"now": time.Now,
|
||||||
|
"toDate": toDate,
|
||||||
|
"unixEpoch": unixEpoch,
|
||||||
|
|
||||||
|
// Strings
|
||||||
|
"trunc": trunc,
|
||||||
|
"trim": strings.TrimSpace,
|
||||||
|
"upper": strings.ToUpper,
|
||||||
|
"lower": strings.ToLower,
|
||||||
|
"title": title,
|
||||||
|
"substr": substring,
|
||||||
|
"repeat": repeat,
|
||||||
|
"trimAll": trimAll,
|
||||||
|
"trimPrefix": trimPrefix,
|
||||||
|
"trimSuffix": trimSuffix,
|
||||||
|
"contains": contains,
|
||||||
|
"hasPrefix": hasPrefix,
|
||||||
|
"hasSuffix": hasSuffix,
|
||||||
|
"quote": quote,
|
||||||
|
"squote": squote,
|
||||||
|
"cat": cat,
|
||||||
|
"indent": indent,
|
||||||
|
"nindent": nindent,
|
||||||
|
"replace": replace,
|
||||||
|
"plural": plural,
|
||||||
|
"sha1sum": sha1sum,
|
||||||
|
"sha256sum": sha256sum,
|
||||||
|
"sha512sum": sha512sum,
|
||||||
|
"adler32sum": adler32sum,
|
||||||
|
"toString": strval,
|
||||||
|
|
||||||
|
// Wrap Atoi to stop errors.
|
||||||
|
"atoi": atoi,
|
||||||
|
"seq": seq,
|
||||||
|
"toDecimal": toDecimal,
|
||||||
|
"split": split,
|
||||||
|
"splitList": splitList,
|
||||||
|
"splitn": splitn,
|
||||||
|
"toStrings": strslice,
|
||||||
|
|
||||||
|
"until": until,
|
||||||
|
"untilStep": untilStep,
|
||||||
|
|
||||||
|
// Basic arithmetic
|
||||||
|
"add1": add1,
|
||||||
|
"add": add,
|
||||||
|
"sub": sub,
|
||||||
|
"div": div,
|
||||||
|
"mod": mod,
|
||||||
|
"mul": mul,
|
||||||
|
"randInt": randInt,
|
||||||
|
"biggest": maxAsInt64,
|
||||||
|
"max": maxAsInt64,
|
||||||
|
"min": minAsInt64,
|
||||||
|
"maxf": maxAsFloat64,
|
||||||
|
"minf": minAsFloat64,
|
||||||
|
"ceil": ceil,
|
||||||
|
"floor": floor,
|
||||||
|
"round": round,
|
||||||
|
|
||||||
|
// string slices. Note that we reverse the order b/c that's better
|
||||||
|
// for template processing.
|
||||||
|
"join": join,
|
||||||
|
"sortAlpha": sortAlpha,
|
||||||
|
|
||||||
|
// Defaults
|
||||||
|
"default": defaultValue,
|
||||||
|
"empty": empty,
|
||||||
|
"coalesce": coalesce,
|
||||||
|
"all": all,
|
||||||
|
"any": anyNonEmpty,
|
||||||
|
"compact": compact,
|
||||||
|
"mustCompact": mustCompact,
|
||||||
|
"fromJSON": fromJSON,
|
||||||
|
"toJSON": toJSON,
|
||||||
|
"toPrettyJSON": toPrettyJSON,
|
||||||
|
"toRawJSON": toRawJSON,
|
||||||
|
"mustFromJSON": mustFromJSON,
|
||||||
|
"mustToJSON": mustToJSON,
|
||||||
|
"mustToPrettyJSON": mustToPrettyJSON,
|
||||||
|
"mustToRawJSON": mustToRawJSON,
|
||||||
|
"ternary": ternary,
|
||||||
|
|
||||||
|
// Reflection
|
||||||
|
"typeOf": typeOf,
|
||||||
|
"typeIs": typeIs,
|
||||||
|
"typeIsLike": typeIsLike,
|
||||||
|
"kindOf": kindOf,
|
||||||
|
"kindIs": kindIs,
|
||||||
|
"deepEqual": reflect.DeepEqual,
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
"base": path.Base,
|
||||||
|
"dir": path.Dir,
|
||||||
|
"clean": path.Clean,
|
||||||
|
"ext": path.Ext,
|
||||||
|
"isAbs": path.IsAbs,
|
||||||
|
|
||||||
|
// Filepaths
|
||||||
|
"osBase": filepath.Base,
|
||||||
|
"osClean": filepath.Clean,
|
||||||
|
"osDir": filepath.Dir,
|
||||||
|
"osExt": filepath.Ext,
|
||||||
|
"osIsAbs": filepath.IsAbs,
|
||||||
|
|
||||||
|
// Encoding
|
||||||
|
"b64enc": base64encode,
|
||||||
|
"b64dec": base64decode,
|
||||||
|
"b32enc": base32encode,
|
||||||
|
"b32dec": base32decode,
|
||||||
|
|
||||||
|
// Data Structures
|
||||||
|
"tuple": list, // FIXME: with the addition of append/prepend these are no longer immutable.
|
||||||
|
"list": list,
|
||||||
|
"dict": dict,
|
||||||
|
"get": get,
|
||||||
|
"set": set,
|
||||||
|
"unset": unset,
|
||||||
|
"hasKey": hasKey,
|
||||||
|
"pluck": pluck,
|
||||||
|
"keys": keys,
|
||||||
|
"pick": pick,
|
||||||
|
"omit": omit,
|
||||||
|
"values": values,
|
||||||
|
|
||||||
|
"append": push,
|
||||||
|
"push": push,
|
||||||
|
"mustAppend": mustPush,
|
||||||
|
"mustPush": mustPush,
|
||||||
|
"prepend": prepend,
|
||||||
|
"mustPrepend": mustPrepend,
|
||||||
|
"first": first,
|
||||||
|
"mustFirst": mustFirst,
|
||||||
|
"rest": rest,
|
||||||
|
"mustRest": mustRest,
|
||||||
|
"last": last,
|
||||||
|
"mustLast": mustLast,
|
||||||
|
"initial": initial,
|
||||||
|
"mustInitial": mustInitial,
|
||||||
|
"reverse": reverse,
|
||||||
|
"mustReverse": mustReverse,
|
||||||
|
"uniq": uniq,
|
||||||
|
"mustUniq": mustUniq,
|
||||||
|
"without": without,
|
||||||
|
"mustWithout": mustWithout,
|
||||||
|
"has": has,
|
||||||
|
"mustHas": mustHas,
|
||||||
|
"slice": slice,
|
||||||
|
"mustSlice": mustSlice,
|
||||||
|
"concat": concat,
|
||||||
|
"dig": dig,
|
||||||
|
"chunk": chunk,
|
||||||
|
"mustChunk": mustChunk,
|
||||||
|
|
||||||
|
// Flow Control
|
||||||
|
"fail": fail,
|
||||||
|
|
||||||
|
// Regex
|
||||||
|
"regexMatch": regexMatch,
|
||||||
|
"mustRegexMatch": mustRegexMatch,
|
||||||
|
"regexFindAll": regexFindAll,
|
||||||
|
"mustRegexFindAll": mustRegexFindAll,
|
||||||
|
"regexFind": regexFind,
|
||||||
|
"mustRegexFind": mustRegexFind,
|
||||||
|
"regexReplaceAll": regexReplaceAll,
|
||||||
|
"mustRegexReplaceAll": mustRegexReplaceAll,
|
||||||
|
"regexReplaceAllLiteral": regexReplaceAllLiteral,
|
||||||
|
"mustRegexReplaceAllLiteral": mustRegexReplaceAllLiteral,
|
||||||
|
"regexSplit": regexSplit,
|
||||||
|
"mustRegexSplit": mustRegexSplit,
|
||||||
|
"regexQuoteMeta": regexQuoteMeta,
|
||||||
|
|
||||||
|
// URLs
|
||||||
|
"urlParse": urlParse,
|
||||||
|
"urlJoin": urlJoin,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
util/sprig/functions_linux_test.go
Normal file
28
util/sprig/functions_linux_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOsBase(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osBase "foo/bar" }}`, "bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsDir(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osDir "foo/bar/baz" }}`, "foo/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsIsAbs(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osIsAbs "/foo" }}`, "true"))
|
||||||
|
assert.NoError(t, runt(`{{ osIsAbs "foo" }}`, "false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsClean(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osClean "/foo/../foo/../bar" }}`, "/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOsExt(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ osExt "/foo/bar/baz.txt" }}`, ".txt"))
|
||||||
|
}
|
||||||
70
util/sprig/functions_test.go
Normal file
70
util/sprig/functions_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBase(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ base "foo/bar" }}`, "bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDir(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ dir "foo/bar/baz" }}`, "foo/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAbs(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ isAbs "/foo" }}`, "true"))
|
||||||
|
assert.NoError(t, runt(`{{ isAbs "foo" }}`, "false"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClean(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ clean "/foo/../foo/../bar" }}`, "/bar"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExt(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ ext "/foo/bar/baz.txt" }}`, ".txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegex(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ regexQuoteMeta "1.2.3" }}`, "1\\.2\\.3"))
|
||||||
|
assert.NoError(t, runt(`{{ regexQuoteMeta "pretzel" }}`, "pretzel"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runt runs a template and checks that the output exactly matches the expected string.
|
||||||
|
func runt(tpl, expect string) error {
|
||||||
|
return runtv(tpl, expect, map[string]string{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runtv takes a template, and expected return, and values for substitution.
|
||||||
|
//
|
||||||
|
// It runs the template and verifies that the output is an exact match.
|
||||||
|
func runtv(tpl, expect string, vars any) error {
|
||||||
|
fmap := TxtFuncMap()
|
||||||
|
t := template.Must(template.New("test").Funcs(fmap).Parse(tpl))
|
||||||
|
var b bytes.Buffer
|
||||||
|
err := t.Execute(&b, vars)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expect != b.String() {
|
||||||
|
return fmt.Errorf("expected '%s', got '%s'", expect, b.String())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRaw runs a template with the given variables and returns the result.
|
||||||
|
func runRaw(tpl string, vars any) (string, error) {
|
||||||
|
fmap := TxtFuncMap()
|
||||||
|
t := template.Must(template.New("test").Funcs(fmap).Parse(tpl))
|
||||||
|
var b bytes.Buffer
|
||||||
|
err := t.Execute(&b, vars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
505
util/sprig/list.go
Normal file
505
util/sprig/list.go
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflection is used in these functions so that slices and arrays of strings,
|
||||||
|
// ints, and other types not implementing []any can be worked with.
|
||||||
|
// For example, this is useful if you need to work on the output of regexs.
|
||||||
|
|
||||||
|
// list creates a new list (slice) containing the provided arguments.
|
||||||
|
// It accepts any number of arguments of any type and returns them as a slice.
|
||||||
|
func list(v ...any) []any {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// push appends an element to the end of a list (slice or array).
|
||||||
|
// It takes a list and a value, and returns a new list with the value appended.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func push(list any, v any) []any {
|
||||||
|
l, err := mustPush(list, v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustPush is the implementation of push that returns an error instead of panicking.
|
||||||
|
// It converts the input list to a slice of any type, then appends the value.
|
||||||
|
func mustPush(list any, v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return append(nl, v), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot push on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepend adds an element to the beginning of a list (slice or array).
|
||||||
|
// It takes a list and a value, and returns a new list with the value at the start.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func prepend(list any, v any) []any {
|
||||||
|
l, err := mustPrepend(list, v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustPrepend is the implementation of prepend that returns an error instead of panicking.
|
||||||
|
// It converts the input list to a slice of any type, then prepends the value.
|
||||||
|
func mustPrepend(list any, v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return append([]any{v}, nl...), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot prepend on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunk divides a list into sub-lists of the specified size.
|
||||||
|
// It takes a size and a list, and returns a list of lists, each containing
|
||||||
|
// up to 'size' elements from the original list.
|
||||||
|
// This function will panic if the second argument is not a slice or array.
|
||||||
|
func chunk(size int, list any) [][]any {
|
||||||
|
l, err := mustChunk(size, list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustChunk is the implementation of chunk that returns an error instead of panicking.
|
||||||
|
// It divides the input list into chunks of the specified size.
|
||||||
|
func mustChunk(size int, list any) ([][]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
numChunks := int(math.Floor(float64(l-1)/float64(size)) + 1)
|
||||||
|
if numChunks > sliceSizeLimit {
|
||||||
|
return nil, fmt.Errorf("number of chunks %d exceeds maximum limit of %d", numChunks, sliceSizeLimit)
|
||||||
|
}
|
||||||
|
result := make([][]any, numChunks)
|
||||||
|
for i := 0; i < numChunks; i++ {
|
||||||
|
clen := size
|
||||||
|
// Handle the last chunk which might be smaller
|
||||||
|
if i == numChunks-1 {
|
||||||
|
clen = int(math.Floor(math.Mod(float64(l), float64(size))))
|
||||||
|
if clen == 0 {
|
||||||
|
clen = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[i] = make([]any, clen)
|
||||||
|
for j := 0; j < clen; j++ {
|
||||||
|
ix := i*size + j
|
||||||
|
result[i][j] = l2.Index(ix).Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot chunk type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// last returns the last element of a list (slice or array).
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func last(list any) any {
|
||||||
|
l, err := mustLast(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustLast is the implementation of last that returns an error instead of panicking.
|
||||||
|
// It returns the last element of the list or nil if the list is empty.
|
||||||
|
func mustLast(list any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return l2.Index(l - 1).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find last on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// first returns the first element of a list (slice or array).
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func first(list any) any {
|
||||||
|
l, err := mustFirst(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustFirst is the implementation of first that returns an error instead of panicking.
|
||||||
|
// It returns the first element of the list or nil if the list is empty.
|
||||||
|
func mustFirst(list any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return l2.Index(0).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find first on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rest returns all elements of a list except the first one.
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func rest(list any) []any {
|
||||||
|
l, err := mustRest(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRest is the implementation of rest that returns an error instead of panicking.
|
||||||
|
// It returns all elements of the list except the first one, or nil if the list is empty.
|
||||||
|
func mustRest(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
nl := make([]any, l-1)
|
||||||
|
for i := 1; i < l; i++ {
|
||||||
|
nl[i-1] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find rest on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial returns all elements of a list except the last one.
|
||||||
|
// If the list is empty, it returns nil.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func initial(list any) []any {
|
||||||
|
l, err := mustInitial(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustInitial is the implementation of initial that returns an error instead of panicking.
|
||||||
|
// It returns all elements of the list except the last one, or nil if the list is empty.
|
||||||
|
func mustInitial(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
nl := make([]any, l-1)
|
||||||
|
for i := 0; i < l-1; i++ {
|
||||||
|
nl[i] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find initial on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortAlpha sorts a list of strings alphabetically.
|
||||||
|
// If the input is not a slice or array, it returns a single-element slice
|
||||||
|
// containing the string representation of the input.
|
||||||
|
func sortAlpha(list any) []string {
|
||||||
|
k := reflect.Indirect(reflect.ValueOf(list)).Kind()
|
||||||
|
switch k {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
a := strslice(list)
|
||||||
|
s := sort.StringSlice(a)
|
||||||
|
s.Sort()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return []string{strval(list)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reverse returns a new list with the elements in reverse order.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func reverse(v any) []any {
|
||||||
|
l, err := mustReverse(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustReverse is the implementation of reverse that returns an error instead of panicking.
|
||||||
|
// It returns a new list with the elements in reverse order.
|
||||||
|
func mustReverse(v any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(v).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(v)
|
||||||
|
l := l2.Len()
|
||||||
|
// We do not sort in place because the incoming array should not be altered.
|
||||||
|
nl := make([]any, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
nl[l-i-1] = l2.Index(i).Interface()
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find reverse on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact returns a new list with all "empty" elements removed.
|
||||||
|
// An element is considered empty if it's nil, zero, an empty string, or an empty collection.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func compact(list any) []any {
|
||||||
|
l, err := mustCompact(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustCompact is the implementation of compact that returns an error instead of panicking.
|
||||||
|
// It returns a new list with all "empty" elements removed.
|
||||||
|
func mustCompact(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
var nl []any
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !empty(item) {
|
||||||
|
nl = append(nl, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nl, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot compact on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniq returns a new list with duplicate elements removed.
|
||||||
|
// The first occurrence of each element is kept.
|
||||||
|
// This function will panic if the argument is not a slice or array.
|
||||||
|
func uniq(list any) []any {
|
||||||
|
l, err := mustUniq(list)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustUniq is the implementation of uniq that returns an error instead of panicking.
|
||||||
|
// It returns a new list with duplicate elements removed.
|
||||||
|
func mustUniq(list any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
var dest []any
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !inList(dest, item) {
|
||||||
|
dest = append(dest, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dest, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find uniq on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inList checks if a value is present in a list.
|
||||||
|
// It uses deep equality comparison to check for matches.
|
||||||
|
// Returns true if the value is found, false otherwise.
|
||||||
|
func inList(haystack []any, needle any) bool {
|
||||||
|
for _, h := range haystack {
|
||||||
|
if reflect.DeepEqual(needle, h) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// without returns a new list with all occurrences of the specified values removed.
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func without(list any, omit ...any) []any {
|
||||||
|
l, err := mustWithout(list, omit...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustWithout is the implementation of without that returns an error instead of panicking.
|
||||||
|
// It returns a new list with all occurrences of the specified values removed.
|
||||||
|
func mustWithout(list any, omit ...any) ([]any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
res := []any{}
|
||||||
|
var item any
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if !inList(omit, item) {
|
||||||
|
res = append(res, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot find without on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// has checks if a value is present in a list.
|
||||||
|
// Returns true if the value is found, false otherwise.
|
||||||
|
// This function will panic if the second argument is not a slice or array.
|
||||||
|
func has(needle any, haystack any) bool {
|
||||||
|
l, err := mustHas(needle, haystack)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustHas is the implementation of has that returns an error instead of panicking.
|
||||||
|
// It checks if a value is present in a list.
|
||||||
|
func mustHas(needle any, haystack any) (bool, error) {
|
||||||
|
if haystack == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
tp := reflect.TypeOf(haystack).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(haystack)
|
||||||
|
var item any
|
||||||
|
l := l2.Len()
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
item = l2.Index(i).Interface()
|
||||||
|
if reflect.DeepEqual(needle, item) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("cannot find has on type %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slice extracts a portion of a list based on the provided indices.
|
||||||
|
// Usage examples:
|
||||||
|
// $list := [1, 2, 3, 4, 5]
|
||||||
|
// slice $list -> list[0:5] = list[:]
|
||||||
|
// slice $list 0 3 -> list[0:3] = list[:3]
|
||||||
|
// slice $list 3 5 -> list[3:5]
|
||||||
|
// slice $list 3 -> list[3:5] = list[3:]
|
||||||
|
//
|
||||||
|
// This function will panic if the first argument is not a slice or array.
|
||||||
|
func slice(list any, indices ...any) any {
|
||||||
|
l, err := mustSlice(list, indices...)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustSlice is the implementation of slice that returns an error instead of panicking.
|
||||||
|
// It extracts a portion of a list based on the provided indices.
|
||||||
|
func mustSlice(list any, indices ...any) (any, error) {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
l := l2.Len()
|
||||||
|
if l == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// Determine start and end indices
|
||||||
|
var start, end int
|
||||||
|
if len(indices) > 0 {
|
||||||
|
start = toInt(indices[0])
|
||||||
|
}
|
||||||
|
if len(indices) < 2 {
|
||||||
|
end = l
|
||||||
|
} else {
|
||||||
|
end = toInt(indices[1])
|
||||||
|
}
|
||||||
|
return l2.Slice(start, end).Interface(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("list should be type of slice or array but %s", tp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// concat combines multiple lists into a single list.
|
||||||
|
// It takes any number of lists and returns a new list containing all elements.
|
||||||
|
// This function will panic if any argument is not a slice or array.
|
||||||
|
func concat(lists ...any) any {
|
||||||
|
var res []any
|
||||||
|
for _, list := range lists {
|
||||||
|
tp := reflect.TypeOf(list).Kind()
|
||||||
|
switch tp {
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
l2 := reflect.ValueOf(list)
|
||||||
|
for i := 0; i < l2.Len(); i++ {
|
||||||
|
res = append(res, l2.Index(i).Interface())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("cannot concat type %s as list", tp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
367
util/sprig/list_test.go
Normal file
367
util/sprig/list_test.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTuple(t *testing.T) {
|
||||||
|
tpl := `{{$t := tuple 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`
|
||||||
|
if err := runt(tpl, "foo1a"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestList(t *testing.T) {
|
||||||
|
tpl := `{{$t := list 1 "a" "foo"}}{{index $t 2}}{{index $t 0 }}{{index $t 1}}`
|
||||||
|
if err := runt(tpl, "foo1a"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPush(t *testing.T) {
|
||||||
|
// Named `append` in the function map
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ append $t 4 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ append $t 5 | join "-" }}`: "1-2-3-4-5",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ append $t "qux" | join "-" }}`: "foo-bar-baz-qux",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustPush(t *testing.T) {
|
||||||
|
// Named `append` in the function map
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ mustAppend $t 4 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ mustAppend $t 5 | join "-" }}`: "1-2-3-4-5",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPush $t "qux" | join "-" }}`: "foo-bar-baz-qux",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunk(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ tuple 1 2 3 4 5 6 7 | chunk 3 | len }}`: "3",
|
||||||
|
`{{ tuple | chunk 3 | len }}`: "0",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|",
|
||||||
|
`{{ range ( tuple 1 2 | chunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustChunk(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ tuple 1 2 3 4 5 6 7 | mustChunk 3 | len }}`: "3",
|
||||||
|
`{{ tuple | mustChunk 3 | len }}`: "0",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 9 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8-9|",
|
||||||
|
`{{ range ( tuple 1 2 3 4 5 6 7 8 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2-3|4-5-6|7-8|",
|
||||||
|
`{{ range ( tuple 1 2 | mustChunk 3 ) }}{{. | join "-"}}|{{end}}`: "1-2|",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
err := runt(`{{ tuple `+strings.Repeat(" 0", 10001)+` | mustChunk 1 }}`, "a")
|
||||||
|
assert.ErrorContains(t, err, "number of chunks 10001 exceeds maximum limit of 10000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepend(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ prepend $t 0 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ prepend $t 0 | join "-" }}`: "0-1-2-3-4",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ prepend $t "qux" | join "-" }}`: "qux-foo-bar-baz",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustPrepend(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ $t := tuple 1 2 3 }}{{ mustPrepend $t 0 | len }}`: "4",
|
||||||
|
`{{ $t := tuple 1 2 3 4 }}{{ mustPrepend $t 0 | join "-" }}`: "0-1-2-3-4",
|
||||||
|
`{{ $t := regexSplit "/" "foo/bar/baz" -1 }}{{ mustPrepend $t "qux" | join "-" }}`: "qux-foo-bar-baz",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirst(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | first }}`: "1",
|
||||||
|
`{{ list | first }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | first }}`: "foo",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustFirst(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustFirst }}`: "1",
|
||||||
|
`{{ list | mustFirst }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | mustFirst }}`: "foo",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLast(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | last }}`: "3",
|
||||||
|
`{{ list | last }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | last }}`: "bar",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustLast(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustLast }}`: "3",
|
||||||
|
`{{ list | mustLast }}`: "<no value>",
|
||||||
|
`{{ regexSplit "/src/" "foo/src/bar" -1 | mustLast }}`: "bar",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitial(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | initial | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | initial | last }}`: "2",
|
||||||
|
`{{ list 1 2 3 | initial | first }}`: "1",
|
||||||
|
`{{ list | initial }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | initial }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustInitial(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustInitial | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustInitial | last }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustInitial | first }}`: "1",
|
||||||
|
`{{ list | mustInitial }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustInitial }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRest(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | rest | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | rest | last }}`: "3",
|
||||||
|
`{{ list 1 2 3 | rest | first }}`: "2",
|
||||||
|
`{{ list | rest }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | rest }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRest(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustRest | len }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustRest | last }}`: "3",
|
||||||
|
`{{ list 1 2 3 | mustRest | first }}`: "2",
|
||||||
|
`{{ list | mustRest }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustRest }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReverse(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | reverse | first }}`: "3",
|
||||||
|
`{{ list 1 2 3 | reverse | rest | first }}`: "2",
|
||||||
|
`{{ list 1 2 3 | reverse | last }}`: "1",
|
||||||
|
`{{ list 1 2 3 4 | reverse }}`: "[4 3 2 1]",
|
||||||
|
`{{ list 1 | reverse }}`: "[1]",
|
||||||
|
`{{ list | reverse }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | reverse }}`: "[baz bar foo]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustReverse(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustReverse | first }}`: "3",
|
||||||
|
`{{ list 1 2 3 | mustReverse | rest | first }}`: "2",
|
||||||
|
`{{ list 1 2 3 | mustReverse | last }}`: "1",
|
||||||
|
`{{ list 1 2 3 4 | mustReverse }}`: "[4 3 2 1]",
|
||||||
|
`{{ list 1 | mustReverse }}`: "[1]",
|
||||||
|
`{{ list | mustReverse }}`: "[]",
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustReverse }}`: "[baz bar foo]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompact(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 0 "" "hello" | compact }}`: `[1 hello]`,
|
||||||
|
`{{ list "" "" | compact }}`: `[]`,
|
||||||
|
`{{ list | compact }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo//bar" -1 | compact }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustCompact(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 0 "" "hello" | mustCompact }}`: `[1 hello]`,
|
||||||
|
`{{ list "" "" | mustCompact }}`: `[]`,
|
||||||
|
`{{ list | mustCompact }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo//bar" -1 | mustCompact }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUniq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 4 | uniq }}`: `[1 2 3 4]`,
|
||||||
|
`{{ list "a" "b" "c" "d" | uniq }}`: `[a b c d]`,
|
||||||
|
`{{ list 1 1 1 1 2 2 2 2 | uniq }}`: `[1 2]`,
|
||||||
|
`{{ list "foo" 1 1 1 1 "foo" "foo" | uniq }}`: `[foo 1]`,
|
||||||
|
`{{ list | uniq }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo/foo/bar" -1 | uniq }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustUniq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 4 | mustUniq }}`: `[1 2 3 4]`,
|
||||||
|
`{{ list "a" "b" "c" "d" | mustUniq }}`: `[a b c d]`,
|
||||||
|
`{{ list 1 1 1 1 2 2 2 2 | mustUniq }}`: `[1 2]`,
|
||||||
|
`{{ list "foo" 1 1 1 1 "foo" "foo" | mustUniq }}`: `[foo 1]`,
|
||||||
|
`{{ list | mustUniq }}`: `[]`,
|
||||||
|
`{{ regexSplit "/" "foo/foo/bar" -1 | mustUniq }}`: "[foo bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithout(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ without (list 1 2 3 4) 1 }}`: `[2 3 4]`,
|
||||||
|
`{{ without (list "a" "b" "c" "d") "a" }}`: `[b c d]`,
|
||||||
|
`{{ without (list 1 1 1 1 2) 1 }}`: `[2]`,
|
||||||
|
`{{ without (list) 1 }}`: `[]`,
|
||||||
|
`{{ without (list 1 2 3) }}`: `[1 2 3]`,
|
||||||
|
`{{ without list }}`: `[]`,
|
||||||
|
`{{ without (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustWithout(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ mustWithout (list 1 2 3 4) 1 }}`: `[2 3 4]`,
|
||||||
|
`{{ mustWithout (list "a" "b" "c" "d") "a" }}`: `[b c d]`,
|
||||||
|
`{{ mustWithout (list 1 1 1 1 2) 1 }}`: `[2]`,
|
||||||
|
`{{ mustWithout (list) 1 }}`: `[]`,
|
||||||
|
`{{ mustWithout (list 1 2 3) }}`: `[1 2 3]`,
|
||||||
|
`{{ mustWithout list }}`: `[]`,
|
||||||
|
`{{ mustWithout (regexSplit "/" "foo/bar/baz" -1 ) "foo" }}`: "[bar baz]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHas(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | has 1 }}`: `true`,
|
||||||
|
`{{ list 1 2 3 | has 4 }}`: `false`,
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | has "bar" }}`: `true`,
|
||||||
|
`{{ has "bar" nil }}`: `false`,
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustHas(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list 1 2 3 | mustHas 1 }}`: `true`,
|
||||||
|
`{{ list 1 2 3 | mustHas 4 }}`: `false`,
|
||||||
|
`{{ regexSplit "/" "foo/bar/baz" -1 | mustHas "bar" }}`: `true`,
|
||||||
|
`{{ mustHas "bar" nil }}`: `false`,
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlice(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ slice (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ slice (list 1 2 3) 0 1 }}`: "[1]",
|
||||||
|
`{{ slice (list 1 2 3) 1 3 }}`: "[2 3]",
|
||||||
|
`{{ slice (list 1 2 3) 1 }}`: "[2 3]",
|
||||||
|
`{{ slice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustSlice(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ mustSlice (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 0 1 }}`: "[1]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 1 3 }}`: "[2 3]",
|
||||||
|
`{{ mustSlice (list 1 2 3) 1 }}`: "[2 3]",
|
||||||
|
`{{ mustSlice (regexSplit "/" "foo/bar/baz" -1) 1 2 }}`: "[bar]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcat(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ concat (list 1 2 3) }}`: "[1 2 3]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) }}`: "[1 2 3 4 5]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list) }}`: "[1 2 3 4 5]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list nil) }}`: "[1 2 3 4 5 <nil>]",
|
||||||
|
`{{ concat (list 1 2 3) (list 4 5) (list ( list "foo" ) ) }}`: "[1 2 3 4 5 [foo]]",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
499
util/sprig/numeric.go
Normal file
499
util/sprig/numeric.go
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toFloat64 converts a value to a 64-bit float.
|
||||||
|
// It handles various input types:
|
||||||
|
// - string: parsed as a float, returns 0 if parsing fails
|
||||||
|
// - integer types: converted to float64
|
||||||
|
// - unsigned integer types: converted to float64
|
||||||
|
// - float types: returned as is
|
||||||
|
// - bool: true becomes 1.0, false becomes 0.0
|
||||||
|
// - other types: returns 0.0
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to float64
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The converted value
|
||||||
|
func toFloat64(v any) float64 {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
iv, err := strconv.ParseFloat(str, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return iv
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.Indirect(reflect.ValueOf(v))
|
||||||
|
switch val.Kind() {
|
||||||
|
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
|
||||||
|
return float64(val.Int())
|
||||||
|
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||||
|
return float64(val.Uint())
|
||||||
|
case reflect.Uint, reflect.Uint64:
|
||||||
|
return float64(val.Uint())
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return val.Float()
|
||||||
|
case reflect.Bool:
|
||||||
|
if val.Bool() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt converts a value to a 32-bit integer.
|
||||||
|
// This is a wrapper around toInt64 that casts the result to int.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to int
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: The converted value
|
||||||
|
func toInt(v any) int {
|
||||||
|
// It's not optimal. But I don't want duplicate toInt64 code.
|
||||||
|
return int(toInt64(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt64 converts a value to a 64-bit integer.
|
||||||
|
// It handles various input types:
|
||||||
|
// - string: parsed as an integer, returns 0 if parsing fails
|
||||||
|
// - integer types: converted to int64
|
||||||
|
// - unsigned integer types: converted to int64 (values > MaxInt64 become MaxInt64)
|
||||||
|
// - float types: truncated to int64
|
||||||
|
// - bool: true becomes 1, false becomes 0
|
||||||
|
// - other types: returns 0
|
||||||
|
func toInt64(v any) int64 {
|
||||||
|
if str, ok := v.(string); ok {
|
||||||
|
iv, err := strconv.ParseInt(str, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return iv
|
||||||
|
}
|
||||||
|
val := reflect.Indirect(reflect.ValueOf(v))
|
||||||
|
switch val.Kind() {
|
||||||
|
case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int:
|
||||||
|
return val.Int()
|
||||||
|
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||||
|
return int64(val.Uint())
|
||||||
|
case reflect.Uint, reflect.Uint64:
|
||||||
|
tv := val.Uint()
|
||||||
|
if tv <= math.MaxInt64 {
|
||||||
|
return int64(tv)
|
||||||
|
}
|
||||||
|
// TODO: What is the sensible thing to do here?
|
||||||
|
return math.MaxInt64
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return int64(val.Float())
|
||||||
|
case reflect.Bool:
|
||||||
|
if val.Bool() {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add1 increments a value by 1.
|
||||||
|
// The input is first converted to int64 using toInt64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - i: The value to increment
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The incremented value
|
||||||
|
func add1(i any) int64 {
|
||||||
|
return toInt64(i) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// add sums all the provided values.
|
||||||
|
// All inputs are converted to int64 using toInt64 before addition.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - i: A variadic list of values to sum
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The sum of all values
|
||||||
|
func add(i ...any) int64 {
|
||||||
|
var a int64
|
||||||
|
for _, b := range i {
|
||||||
|
a += toInt64(b)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// sub subtracts the second value from the first.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before subtraction.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to subtract from
|
||||||
|
// - b: The value to subtract
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The result of a - b
|
||||||
|
func sub(a, b any) int64 {
|
||||||
|
return toInt64(a) - toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// div divides the first value by the second.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before division.
|
||||||
|
// Note: This performs integer division, so the result is truncated.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The dividend
|
||||||
|
// - b: The divisor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The result of a / b
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If b evaluates to 0 (division by zero)
|
||||||
|
func div(a, b any) int64 {
|
||||||
|
return toInt64(a) / toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mod returns the remainder of dividing the first value by the second.
|
||||||
|
// Both inputs are converted to int64 using toInt64 before the modulo operation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The dividend
|
||||||
|
// - b: The divisor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The remainder of a / b
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If b evaluates to 0 (modulo by zero)
|
||||||
|
func mod(a, b any) int64 {
|
||||||
|
return toInt64(a) % toInt64(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mul multiplies all the provided values.
|
||||||
|
// All inputs are converted to int64 using toInt64 before multiplication.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to multiply
|
||||||
|
// - v: Additional values to multiply with a
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The product of all values
|
||||||
|
func mul(a any, v ...any) int64 {
|
||||||
|
val := toInt64(a)
|
||||||
|
for _, b := range v {
|
||||||
|
val = val * toInt64(b)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// randInt generates a random integer between min (inclusive) and max (exclusive).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - min: The lower bound (inclusive)
|
||||||
|
// - max: The upper bound (exclusive)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: A random integer in the range [min, max)
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If max <= min (via rand.Intn)
|
||||||
|
func randInt(min, max int) int {
|
||||||
|
return rand.Intn(max-min) + min
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAsInt64 returns the maximum value from a list of values as an int64.
|
||||||
|
// All inputs are converted to int64 using toInt64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The maximum value from all inputs
|
||||||
|
func maxAsInt64(a any, i ...any) int64 {
|
||||||
|
aa := toInt64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
bb := toInt64(b)
|
||||||
|
if bb > aa {
|
||||||
|
aa = bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aa
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAsFloat64 returns the maximum value from a list of values as a float64.
|
||||||
|
// All inputs are converted to float64 using toFloat64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The maximum value from all inputs
|
||||||
|
func maxAsFloat64(a any, i ...any) float64 {
|
||||||
|
m := toFloat64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
m = math.Max(m, toFloat64(b))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// minAsInt64 returns the minimum value from a list of values as an int64.
|
||||||
|
// All inputs are converted to int64 using toInt64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The minimum value from all inputs
|
||||||
|
func minAsInt64(a any, i ...any) int64 {
|
||||||
|
aa := toInt64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
bb := toInt64(b)
|
||||||
|
if bb < aa {
|
||||||
|
aa = bb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aa
|
||||||
|
}
|
||||||
|
|
||||||
|
// minAsFloat64 returns the minimum value from a list of values as a float64.
|
||||||
|
// All inputs are converted to float64 using toFloat64 before comparison.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The first value to compare
|
||||||
|
// - i: Additional values to compare
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The minimum value from all inputs
|
||||||
|
func minAsFloat64(a any, i ...any) float64 {
|
||||||
|
m := toFloat64(a)
|
||||||
|
for _, b := range i {
|
||||||
|
m = math.Min(m, toFloat64(b))
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// until generates a sequence of integers from 0 to count (exclusive).
|
||||||
|
// If count is negative, it generates a sequence from 0 to count (inclusive) with step -1.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - count: The end value (exclusive if positive, inclusive if negative)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []int: A slice containing the generated sequence
|
||||||
|
func until(count int) []int {
|
||||||
|
step := 1
|
||||||
|
if count < 0 {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
return untilStep(0, count, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// untilStep generates a sequence of integers from start to stop with the specified step.
|
||||||
|
// The sequence is generated as follows:
|
||||||
|
// - If step is 0, returns an empty slice
|
||||||
|
// - If stop < start and step < 0, generates a decreasing sequence from start to stop (exclusive)
|
||||||
|
// - If stop > start and step > 0, generates an increasing sequence from start to stop (exclusive)
|
||||||
|
// - Otherwise, returns an empty slice
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - start: The starting value (inclusive)
|
||||||
|
// - stop: The ending value (exclusive)
|
||||||
|
// - step: The increment between values
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []int: A slice containing the generated sequence
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If the number of iterations would exceed loopExecutionLimit
|
||||||
|
func untilStep(start, stop, step int) []int {
|
||||||
|
var v []int
|
||||||
|
if step == 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
iterations := math.Abs(float64(stop)-float64(start)) / float64(step)
|
||||||
|
if iterations > loopExecutionLimit {
|
||||||
|
panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations))
|
||||||
|
}
|
||||||
|
if stop < start {
|
||||||
|
if step >= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for i := start; i > stop; i += step {
|
||||||
|
v = append(v, i)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if step <= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for i := start; i < stop; i += step {
|
||||||
|
v = append(v, i)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// floor returns the greatest integer value less than or equal to the input.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to floor
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The greatest integer value less than or equal to a
|
||||||
|
func floor(a any) float64 {
|
||||||
|
return math.Floor(toFloat64(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ceil returns the least integer value greater than or equal to the input.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to ceil
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The least integer value greater than or equal to a
|
||||||
|
func ceil(a any) float64 {
|
||||||
|
return math.Ceil(toFloat64(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
// round rounds a number to a specified number of decimal places.
|
||||||
|
// The input is first converted to float64 using toFloat64.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The value to round
|
||||||
|
// - p: The number of decimal places to round to
|
||||||
|
// - rOpt: Optional rounding threshold (default is 0.5)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - float64: The rounded value
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
// - round(3.14159, 2) returns 3.14
|
||||||
|
// - round(3.14159, 2, 0.6) returns 3.14 (only rounds up if fraction ≥ 0.6)
|
||||||
|
func round(a any, p int, rOpt ...float64) float64 {
|
||||||
|
roundOn := .5
|
||||||
|
if len(rOpt) > 0 {
|
||||||
|
roundOn = rOpt[0]
|
||||||
|
}
|
||||||
|
val := toFloat64(a)
|
||||||
|
places := toFloat64(p)
|
||||||
|
var round float64
|
||||||
|
pow := math.Pow(10, places)
|
||||||
|
digit := pow * val
|
||||||
|
_, div := math.Modf(digit)
|
||||||
|
if div >= roundOn {
|
||||||
|
round = math.Ceil(digit)
|
||||||
|
} else {
|
||||||
|
round = math.Floor(digit)
|
||||||
|
}
|
||||||
|
return round / pow
|
||||||
|
}
|
||||||
|
|
||||||
|
// toDecimal converts a value from octal to decimal.
|
||||||
|
// The input is first converted to a string using fmt.Sprint, then parsed as an octal number.
|
||||||
|
// If the parsing fails, it returns 0.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The octal value to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int64: The decimal representation of the octal value
|
||||||
|
func toDecimal(v any) int64 {
|
||||||
|
result, err := strconv.ParseInt(fmt.Sprint(v), 8, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoi converts a string to an integer.
|
||||||
|
// If the conversion fails, it returns 0.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The string to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - int: The integer value of the string
|
||||||
|
func atoi(a string) int {
|
||||||
|
i, _ := strconv.Atoi(a)
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// seq generates a sequence of integers and returns them as a space-delimited string.
|
||||||
|
// The behavior depends on the number of parameters:
|
||||||
|
// - 0 params: Returns an empty string
|
||||||
|
// - 1 param: Generates sequence from 1 to param[0]
|
||||||
|
// - 2 params: Generates sequence from param[0] to param[1]
|
||||||
|
// - 3 params: Generates sequence from param[0] to param[2] with step param[1]
|
||||||
|
//
|
||||||
|
// If the end is less than the start, the sequence will be decreasing unless
|
||||||
|
// a positive step is explicitly provided (which would result in an empty string).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - params: Variable number of integers defining the sequence
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A space-delimited string of the generated sequence
|
||||||
|
func seq(params ...int) string {
|
||||||
|
increment := 1
|
||||||
|
switch len(params) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
start := 1
|
||||||
|
end := params[0]
|
||||||
|
if end < start {
|
||||||
|
increment = -1
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+increment, increment), " ")
|
||||||
|
case 3:
|
||||||
|
start := params[0]
|
||||||
|
end := params[2]
|
||||||
|
step := params[1]
|
||||||
|
if end < start {
|
||||||
|
increment = -1
|
||||||
|
if step > 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+increment, step), " ")
|
||||||
|
case 2:
|
||||||
|
start := params[0]
|
||||||
|
end := params[1]
|
||||||
|
step := 1
|
||||||
|
if end < start {
|
||||||
|
step = -1
|
||||||
|
}
|
||||||
|
return intArrayToString(untilStep(start, end+step, step), " ")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intArrayToString converts a slice of integers to a space-delimited string.
|
||||||
|
// The function removes the square brackets that would normally appear when
|
||||||
|
// converting a slice to a string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - slice: The slice of integers to convert
|
||||||
|
// - delimiter: The delimiter to use between elements
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A delimited string representation of the integer slice
|
||||||
|
func intArrayToString(slice []int, delimiter string) string {
|
||||||
|
return strings.Trim(strings.Join(strings.Fields(fmt.Sprint(slice)), delimiter), "[]")
|
||||||
|
}
|
||||||
307
util/sprig/numeric_test.go
Normal file
307
util/sprig/numeric_test.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUntil(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{range $i, $e := until 5}}{{$i}}{{$e}}{{end}}`: "0011223344",
|
||||||
|
`{{range $i, $e := until -5}}{{$i}}{{$e}} {{end}}`: "00 1-1 2-2 3-3 4-4 ",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUntilStep(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{range $i, $e := untilStep 0 5 1}}{{$i}}{{$e}}{{end}}`: "0011223344",
|
||||||
|
`{{range $i, $e := untilStep 3 6 1}}{{$i}}{{$e}}{{end}}`: "031425",
|
||||||
|
`{{range $i, $e := untilStep 0 -10 -2}}{{$i}}{{$e}} {{end}}`: "00 1-2 2-4 3-6 4-8 ",
|
||||||
|
`{{range $i, $e := untilStep 3 0 1}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 99 0}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 99 -1}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
`{{range $i, $e := untilStep 3 0 0}}{{$i}}{{$e}}{{end}}`: "",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
func TestBiggest(t *testing.T) {
|
||||||
|
tpl := `{{ biggest 1 2 3 345 5 6 7}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ max 345}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestMaxf(t *testing.T) {
|
||||||
|
tpl := `{{ maxf 1 2 3 345.7 5 6 7}}`
|
||||||
|
if err := runt(tpl, `345.7`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ max 345 }}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestMin(t *testing.T) {
|
||||||
|
tpl := `{{ min 1 2 3 345 5 6 7}}`
|
||||||
|
if err := runt(tpl, `1`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ min 345}}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinf(t *testing.T) {
|
||||||
|
tpl := `{{ minf 1.4 2 3 345.6 5 6 7}}`
|
||||||
|
if err := runt(tpl, `1.4`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl = `{{ minf 345 }}`
|
||||||
|
if err := runt(tpl, `345`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToFloat64(t *testing.T) {
|
||||||
|
target := float64(102)
|
||||||
|
if target != toFloat64(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toFloat64("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toFloat64(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toFloat64(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toFloat64(float64(102.1234)) != 102.1234 {
|
||||||
|
t.Errorf("Expected 102.1234")
|
||||||
|
}
|
||||||
|
if toFloat64(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestToInt64(t *testing.T) {
|
||||||
|
target := int64(102)
|
||||||
|
if target != toInt64(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt64("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toInt64(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt64(float64(102.1234)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt64(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToInt(t *testing.T) {
|
||||||
|
target := int(102)
|
||||||
|
if target != toInt(int8(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int32(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(int64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt("102") {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt("frankie") != 0 {
|
||||||
|
t.Errorf("Expected 0")
|
||||||
|
}
|
||||||
|
if target != toInt(uint16(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(uint64(102)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if target != toInt(float64(102.1234)) {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
if toInt(true) != 1 {
|
||||||
|
t.Errorf("Expected 102")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToDecimal(t *testing.T) {
|
||||||
|
tests := map[any]int64{
|
||||||
|
"777": 511,
|
||||||
|
777: 511,
|
||||||
|
770: 504,
|
||||||
|
755: 493,
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, expectedResult := range tests {
|
||||||
|
result := toDecimal(input)
|
||||||
|
if result != expectedResult {
|
||||||
|
t.Errorf("Expected %v but got %v", expectedResult, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdd1(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | add1 }}`
|
||||||
|
if err := runt(tpl, `4`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdd(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | add 1 2}}`
|
||||||
|
if err := runt(tpl, `6`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiv(t *testing.T) {
|
||||||
|
tpl := `{{ 4 | div 5 }}`
|
||||||
|
if err := runt(tpl, `1`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMul(t *testing.T) {
|
||||||
|
tpl := `{{ 1 | mul "2" 3 "4"}}`
|
||||||
|
if err := runt(tpl, `24`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSub(t *testing.T) {
|
||||||
|
tpl := `{{ 3 | sub 14 }}`
|
||||||
|
if err := runt(tpl, `11`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeil(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.0, ceil(123))
|
||||||
|
assert.Equal(t, 123.0, ceil("123"))
|
||||||
|
assert.Equal(t, 124.0, ceil(123.01))
|
||||||
|
assert.Equal(t, 124.0, ceil("123.01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloor(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.0, floor(123))
|
||||||
|
assert.Equal(t, 123.0, floor("123"))
|
||||||
|
assert.Equal(t, 123.0, floor(123.9999))
|
||||||
|
assert.Equal(t, 123.0, floor("123.9999"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRound(t *testing.T) {
|
||||||
|
assert.Equal(t, 123.556, round(123.5555, 3))
|
||||||
|
assert.Equal(t, 123.556, round("123.55555", 3))
|
||||||
|
assert.Equal(t, 124.0, round(123.500001, 0))
|
||||||
|
assert.Equal(t, 123.0, round(123.49999999, 0))
|
||||||
|
assert.Equal(t, 123.23, round(123.2329999, 2, .3))
|
||||||
|
assert.Equal(t, 123.24, round(123.233, 2, .3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRandomInt(t *testing.T) {
|
||||||
|
var tests = []struct {
|
||||||
|
min int
|
||||||
|
max int
|
||||||
|
}{
|
||||||
|
{10, 11},
|
||||||
|
{10, 13},
|
||||||
|
{0, 1},
|
||||||
|
{5, 50},
|
||||||
|
}
|
||||||
|
for _, v := range tests {
|
||||||
|
x, _ := runRaw(fmt.Sprintf(`{{ randInt %d %d }}`, v.min, v.max), nil)
|
||||||
|
r, err := strconv.Atoi(x)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, func(min, max, r int) bool {
|
||||||
|
return r >= v.min && r < v.max
|
||||||
|
}(v.min, v.max, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeq(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{seq 0 1 3}}`: "0 1 2 3",
|
||||||
|
`{{seq 0 3 10}}`: "0 3 6 9",
|
||||||
|
`{{seq 3 3 2}}`: "",
|
||||||
|
`{{seq 3 -3 2}}`: "3",
|
||||||
|
`{{seq}}`: "",
|
||||||
|
`{{seq 0 4}}`: "0 1 2 3 4",
|
||||||
|
`{{seq 5}}`: "1 2 3 4 5",
|
||||||
|
`{{seq -5}}`: "1 0 -1 -2 -3 -4 -5",
|
||||||
|
`{{seq 0}}`: "1 0",
|
||||||
|
`{{seq 0 1 2 3}}`: "",
|
||||||
|
`{{seq 0 -4}}`: "0 -1 -2 -3 -4",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
util/sprig/reflect.go
Normal file
70
util/sprig/reflect.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// typeIs returns true if the src is the type named in target.
|
||||||
|
// It compares the type name of src with the target string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The type name to check against
|
||||||
|
// - src: The value whose type will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the type name of src matches target, false otherwise
|
||||||
|
func typeIs(target string, src any) bool {
|
||||||
|
return target == typeOf(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeIsLike returns true if the src is the type named in target or a pointer to that type.
|
||||||
|
// This is useful when you need to check for both a type and a pointer to that type.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The type name to check against
|
||||||
|
// - src: The value whose type will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the type of src matches target or "*"+target, false otherwise
|
||||||
|
func typeIsLike(target string, src any) bool {
|
||||||
|
t := typeOf(src)
|
||||||
|
return target == t || "*"+target == t
|
||||||
|
}
|
||||||
|
|
||||||
|
// typeOf returns the type of a value as a string.
|
||||||
|
// It uses fmt.Sprintf with the %T format verb to get the type name.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - src: The value whose type name will be returned
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The type name of src
|
||||||
|
func typeOf(src any) string {
|
||||||
|
return fmt.Sprintf("%T", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindIs returns true if the kind of src matches the target kind.
|
||||||
|
// This checks the underlying kind (e.g., "string", "int", "map") rather than the specific type.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - target: The kind name to check against
|
||||||
|
// - src: The value whose kind will be checked
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the kind of src matches target, false otherwise
|
||||||
|
func kindIs(target string, src any) bool {
|
||||||
|
return target == kindOf(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindOf returns the kind of a value as a string.
|
||||||
|
// The kind represents the specific Go type category (e.g., "string", "int", "map", "slice").
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - src: The value whose kind will be returned
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The kind of src as a string
|
||||||
|
func kindOf(src any) string {
|
||||||
|
return reflect.ValueOf(src).Kind().String()
|
||||||
|
}
|
||||||
73
util/sprig/reflect_test.go
Normal file
73
util/sprig/reflect_test.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixtureTO struct {
|
||||||
|
Name, Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeOf(t *testing.T) {
|
||||||
|
f := &fixtureTO{"hello", "world"}
|
||||||
|
tpl := `{{typeOf .}}`
|
||||||
|
if err := runtv(tpl, "*sprig.fixtureTO", f); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKindOf(t *testing.T) {
|
||||||
|
tpl := `{{kindOf .}}`
|
||||||
|
|
||||||
|
f := fixtureTO{"hello", "world"}
|
||||||
|
if err := runtv(tpl, "struct", f); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f2 := []string{"hello"}
|
||||||
|
if err := runtv(tpl, "slice", f2); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var f3 *fixtureTO
|
||||||
|
if err := runtv(tpl, "ptr", f3); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTypeIs(t *testing.T) {
|
||||||
|
f := &fixtureTO{"hello", "world"}
|
||||||
|
tpl := `{{if typeIs "*sprig.fixtureTO" .}}t{{else}}f{{end}}`
|
||||||
|
if err := runtv(tpl, "t", f); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f2 := "hello"
|
||||||
|
if err := runtv(tpl, "f", f2); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestTypeIsLike(t *testing.T) {
|
||||||
|
f := "foo"
|
||||||
|
tpl := `{{if typeIsLike "string" .}}t{{else}}f{{end}}`
|
||||||
|
if err := runtv(tpl, "t", f); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now make a pointer. Should still match.
|
||||||
|
f2 := &f
|
||||||
|
if err := runtv(tpl, "t", f2); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestKindIs(t *testing.T) {
|
||||||
|
f := &fixtureTO{"hello", "world"}
|
||||||
|
tpl := `{{if kindIs "ptr" .}}t{{else}}f{{end}}`
|
||||||
|
if err := runtv(tpl, "t", f); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
f2 := "hello"
|
||||||
|
if err := runtv(tpl, "f", f2); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
217
util/sprig/regex.go
Normal file
217
util/sprig/regex.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// regexMatch checks if a string matches a regular expression pattern.
|
||||||
|
// It ignores any errors that might occur during regex compilation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to match against
|
||||||
|
// - s: The string to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the string matches the pattern, false otherwise
|
||||||
|
func regexMatch(regex string, s string) bool {
|
||||||
|
match, _ := regexp.MatchString(regex, s)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexMatch checks if a string matches a regular expression pattern.
|
||||||
|
// Unlike regexMatch, this function returns any errors that occur during regex compilation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to match against
|
||||||
|
// - s: The string to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if the string matches the pattern, false otherwise
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexMatch(regex string, s string) (bool, error) {
|
||||||
|
return regexp.MatchString(regex, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexFindAll finds all matches of a regular expression in a string.
|
||||||
|
// It panics if the regex pattern cannot be compiled.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - n: The maximum number of matches to return (negative means all matches)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing all matched substrings
|
||||||
|
func regexFindAll(regex string, s string, n int) []string {
|
||||||
|
r := regexp.MustCompile(regex)
|
||||||
|
return r.FindAllString(s, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexFindAll finds all matches of a regular expression in a string.
|
||||||
|
// Unlike regexFindAll, this function returns any errors that occur during regex compilation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - n: The maximum number of matches to return (negative means all matches)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing all matched substrings
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexFindAll(regex string, s string, n int) ([]string, error) {
|
||||||
|
r, err := regexp.Compile(regex)
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, err
|
||||||
|
}
|
||||||
|
return r.FindAllString(s, n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexFind finds the first match of a regular expression in a string.
|
||||||
|
// It panics if the regex pattern cannot be compiled.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The first matched substring, or an empty string if no match
|
||||||
|
func regexFind(regex string, s string) string {
|
||||||
|
r := regexp.MustCompile(regex)
|
||||||
|
return r.FindString(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexFind finds the first match of a regular expression in a string.
|
||||||
|
// Unlike regexFind, this function returns any errors that occur during regex compilation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The first matched substring, or an empty string if no match
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexFind(regex string, s string) (string, error) {
|
||||||
|
r, err := regexp.Compile(regex)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.FindString(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexReplaceAll replaces all matches of a regular expression with a replacement string.
|
||||||
|
// It panics if the regex pattern cannot be compiled.
|
||||||
|
// The replacement string can contain $1, $2, etc. for submatches.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - repl: The replacement string (can contain $1, $2, etc. for submatches)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The resulting string after all replacements
|
||||||
|
func regexReplaceAll(regex string, s string, repl string) string {
|
||||||
|
r := regexp.MustCompile(regex)
|
||||||
|
return r.ReplaceAllString(s, repl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexReplaceAll replaces all matches of a regular expression with a replacement string.
|
||||||
|
// Unlike regexReplaceAll, this function returns any errors that occur during regex compilation.
|
||||||
|
// The replacement string can contain $1, $2, etc. for submatches.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - repl: The replacement string (can contain $1, $2, etc. for submatches)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The resulting string after all replacements
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexReplaceAll(regex string, s string, repl string) (string, error) {
|
||||||
|
r, err := regexp.Compile(regex)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.ReplaceAllString(s, repl), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string.
|
||||||
|
// It panics if the regex pattern cannot be compiled.
|
||||||
|
// Unlike regexReplaceAll, the replacement string is used literally (no $1, $2 processing).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - repl: The literal replacement string
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The resulting string after all replacements
|
||||||
|
func regexReplaceAllLiteral(regex string, s string, repl string) string {
|
||||||
|
r := regexp.MustCompile(regex)
|
||||||
|
return r.ReplaceAllLiteralString(s, repl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexReplaceAllLiteral replaces all matches of a regular expression with a literal replacement string.
|
||||||
|
// Unlike regexReplaceAllLiteral, this function returns any errors that occur during regex compilation.
|
||||||
|
// The replacement string is used literally (no $1, $2 processing).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to search for
|
||||||
|
// - s: The string to search within
|
||||||
|
// - repl: The literal replacement string
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The resulting string after all replacements
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexReplaceAllLiteral(regex string, s string, repl string) (string, error) {
|
||||||
|
r, err := regexp.Compile(regex)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.ReplaceAllLiteralString(s, repl), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexSplit splits a string by a regular expression pattern.
|
||||||
|
// It panics if the regex pattern cannot be compiled.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to split on
|
||||||
|
// - s: The string to split
|
||||||
|
// - n: The maximum number of substrings to return (negative means all substrings)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing the substrings between regex matches
|
||||||
|
func regexSplit(regex string, s string, n int) []string {
|
||||||
|
r := regexp.MustCompile(regex)
|
||||||
|
return r.Split(s, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustRegexSplit splits a string by a regular expression pattern.
|
||||||
|
// Unlike regexSplit, this function returns any errors that occur during regex compilation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - regex: The regular expression pattern to split on
|
||||||
|
// - s: The string to split
|
||||||
|
// - n: The maximum number of substrings to return (negative means all substrings)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing the substrings between regex matches
|
||||||
|
// - error: Any error that occurred during regex compilation
|
||||||
|
func mustRegexSplit(regex string, s string, n int) ([]string, error) {
|
||||||
|
r, err := regexp.Compile(regex)
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, err
|
||||||
|
}
|
||||||
|
return r.Split(s, n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexQuoteMeta escapes all regular expression metacharacters in a string.
|
||||||
|
// This is useful when you want to use a string as a literal in a regular expression.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The string to escape
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The escaped string with all regex metacharacters quoted
|
||||||
|
func regexQuoteMeta(s string) string {
|
||||||
|
return regexp.QuoteMeta(s)
|
||||||
|
}
|
||||||
203
util/sprig/regex_test.go
Normal file
203
util/sprig/regex_test.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegexMatch(t *testing.T) {
|
||||||
|
regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
|
||||||
|
|
||||||
|
assert.True(t, regexMatch(regex, "test@acme.com"))
|
||||||
|
assert.True(t, regexMatch(regex, "Test@Acme.Com"))
|
||||||
|
assert.False(t, regexMatch(regex, "test"))
|
||||||
|
assert.False(t, regexMatch(regex, "test.com"))
|
||||||
|
assert.False(t, regexMatch(regex, "test@acme"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexMatch(t *testing.T) {
|
||||||
|
regex := "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"
|
||||||
|
|
||||||
|
o, err := mustRegexMatch(regex, "test@acme.com")
|
||||||
|
assert.True(t, o)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
o, err = mustRegexMatch(regex, "Test@Acme.Com")
|
||||||
|
assert.True(t, o)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
o, err = mustRegexMatch(regex, "test")
|
||||||
|
assert.False(t, o)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
o, err = mustRegexMatch(regex, "test.com")
|
||||||
|
assert.False(t, o)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
|
||||||
|
o, err = mustRegexMatch(regex, "test@acme")
|
||||||
|
assert.False(t, o)
|
||||||
|
assert.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexFindAll(t *testing.T) {
|
||||||
|
regex := "a{2}"
|
||||||
|
assert.Equal(t, 1, len(regexFindAll(regex, "aa", -1)))
|
||||||
|
assert.Equal(t, 1, len(regexFindAll(regex, "aaaaaaaa", 1)))
|
||||||
|
assert.Equal(t, 2, len(regexFindAll(regex, "aaaa", -1)))
|
||||||
|
assert.Equal(t, 0, len(regexFindAll(regex, "none", -1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexFindAll(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
regex, s string
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
expected int
|
||||||
|
args args
|
||||||
|
}{
|
||||||
|
{1, args{"a{2}", "aa", -1}},
|
||||||
|
{1, args{"a{2}", "aaaaaaaa", 1}},
|
||||||
|
{2, args{"a{2}", "aaaa", -1}},
|
||||||
|
{0, args{"a{2}", "none", -1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
res, err := mustRegexFindAll(c.args.regex, c.args.s, c.args.n)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("regexFindAll test case %v failed with err %s", c, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, c.expected, len(res), "case %#v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexFindl(t *testing.T) {
|
||||||
|
regex := "fo.?"
|
||||||
|
assert.Equal(t, "foo", regexFind(regex, "foorbar"))
|
||||||
|
assert.Equal(t, "foo", regexFind(regex, "foo foe fome"))
|
||||||
|
assert.Equal(t, "", regexFind(regex, "none"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexFindl(t *testing.T) {
|
||||||
|
type args struct{ regex, s string }
|
||||||
|
cases := []struct {
|
||||||
|
expected string
|
||||||
|
args args
|
||||||
|
}{
|
||||||
|
{"foo", args{"fo.?", "foorbar"}},
|
||||||
|
{"foo", args{"fo.?", "foo foe fome"}},
|
||||||
|
{"", args{"fo.?", "none"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
res, err := mustRegexFind(c.args.regex, c.args.s)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("regexFind test case %v failed with err %s", c, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, c.expected, res, "case %#v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexReplaceAll(t *testing.T) {
|
||||||
|
regex := "a(x*)b"
|
||||||
|
assert.Equal(t, "-T-T-", regexReplaceAll(regex, "-ab-axxb-", "T"))
|
||||||
|
assert.Equal(t, "--xx-", regexReplaceAll(regex, "-ab-axxb-", "$1"))
|
||||||
|
assert.Equal(t, "---", regexReplaceAll(regex, "-ab-axxb-", "$1W"))
|
||||||
|
assert.Equal(t, "-W-xxW-", regexReplaceAll(regex, "-ab-axxb-", "${1}W"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexReplaceAll(t *testing.T) {
|
||||||
|
type args struct{ regex, s, repl string }
|
||||||
|
cases := []struct {
|
||||||
|
expected string
|
||||||
|
args args
|
||||||
|
}{
|
||||||
|
{"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}},
|
||||||
|
{"--xx-", args{"a(x*)b", "-ab-axxb-", "$1"}},
|
||||||
|
{"---", args{"a(x*)b", "-ab-axxb-", "$1W"}},
|
||||||
|
{"-W-xxW-", args{"a(x*)b", "-ab-axxb-", "${1}W"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
res, err := mustRegexReplaceAll(c.args.regex, c.args.s, c.args.repl)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("regexReplaceAll test case %v failed with err %s", c, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, c.expected, res, "case %#v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexReplaceAllLiteral(t *testing.T) {
|
||||||
|
regex := "a(x*)b"
|
||||||
|
assert.Equal(t, "-T-T-", regexReplaceAllLiteral(regex, "-ab-axxb-", "T"))
|
||||||
|
assert.Equal(t, "-$1-$1-", regexReplaceAllLiteral(regex, "-ab-axxb-", "$1"))
|
||||||
|
assert.Equal(t, "-${1}-${1}-", regexReplaceAllLiteral(regex, "-ab-axxb-", "${1}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexReplaceAllLiteral(t *testing.T) {
|
||||||
|
type args struct{ regex, s, repl string }
|
||||||
|
cases := []struct {
|
||||||
|
expected string
|
||||||
|
args args
|
||||||
|
}{
|
||||||
|
{"-T-T-", args{"a(x*)b", "-ab-axxb-", "T"}},
|
||||||
|
{"-$1-$1-", args{"a(x*)b", "-ab-axxb-", "$1"}},
|
||||||
|
{"-${1}-${1}-", args{"a(x*)b", "-ab-axxb-", "${1}"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
res, err := mustRegexReplaceAllLiteral(c.args.regex, c.args.s, c.args.repl)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("regexReplaceAllLiteral test case %v failed with err %s", c, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, c.expected, res, "case %#v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexSplit(t *testing.T) {
|
||||||
|
regex := "a"
|
||||||
|
assert.Equal(t, 4, len(regexSplit(regex, "banana", -1)))
|
||||||
|
assert.Equal(t, 0, len(regexSplit(regex, "banana", 0)))
|
||||||
|
assert.Equal(t, 1, len(regexSplit(regex, "banana", 1)))
|
||||||
|
assert.Equal(t, 2, len(regexSplit(regex, "banana", 2)))
|
||||||
|
|
||||||
|
regex = "z+"
|
||||||
|
assert.Equal(t, 2, len(regexSplit(regex, "pizza", -1)))
|
||||||
|
assert.Equal(t, 0, len(regexSplit(regex, "pizza", 0)))
|
||||||
|
assert.Equal(t, 1, len(regexSplit(regex, "pizza", 1)))
|
||||||
|
assert.Equal(t, 2, len(regexSplit(regex, "pizza", 2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMustRegexSplit(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
regex, s string
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
expected int
|
||||||
|
args args
|
||||||
|
}{
|
||||||
|
{4, args{"a", "banana", -1}},
|
||||||
|
{0, args{"a", "banana", 0}},
|
||||||
|
{1, args{"a", "banana", 1}},
|
||||||
|
{2, args{"a", "banana", 2}},
|
||||||
|
{2, args{"z+", "pizza", -1}},
|
||||||
|
{0, args{"z+", "pizza", 0}},
|
||||||
|
{1, args{"z+", "pizza", 1}},
|
||||||
|
{2, args{"z+", "pizza", 2}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
res, err := mustRegexSplit(c.args.regex, c.args.s, c.args.n)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("regexSplit test case %v failed with err %s", c, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, c.expected, len(res), "case %#v", c.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegexQuoteMeta(t *testing.T) {
|
||||||
|
assert.Equal(t, "1\\.2\\.3", regexQuoteMeta("1.2.3"))
|
||||||
|
assert.Equal(t, "pretzel", regexQuoteMeta("pretzel"))
|
||||||
|
}
|
||||||
487
util/sprig/strings.go
Normal file
487
util/sprig/strings.go
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// base64encode encodes a string to base64 using standard encoding.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The string to encode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The base64 encoded string
|
||||||
|
func base64encode(v string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64decode decodes a base64 encoded string.
|
||||||
|
// If the input is not valid base64, it returns the error message as a string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The base64 encoded string to decode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The decoded string, or an error message if decoding fails
|
||||||
|
func base64decode(v string) string {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(v)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// base32encode encodes a string to base32 using standard encoding.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The string to encode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The base32 encoded string
|
||||||
|
func base32encode(v string) string {
|
||||||
|
return base32.StdEncoding.EncodeToString([]byte(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// base32decode decodes a base32 encoded string.
|
||||||
|
// If the input is not valid base32, it returns the error message as a string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The base32 encoded string to decode
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The decoded string, or an error message if decoding fails
|
||||||
|
func base32decode(v string) string {
|
||||||
|
data, err := base32.StdEncoding.DecodeString(v)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quote adds double quotes around each non-nil string in the input and joins them with spaces.
|
||||||
|
// This uses Go's %q formatter which handles escaping special characters.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - str: A variadic list of values to quote
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The quoted strings joined with spaces
|
||||||
|
func quote(str ...any) string {
|
||||||
|
out := make([]string, 0, len(str))
|
||||||
|
for _, s := range str {
|
||||||
|
if s != nil {
|
||||||
|
out = append(out, fmt.Sprintf("%q", strval(s)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// squote adds single quotes around each non-nil value in the input and joins them with spaces.
|
||||||
|
// Unlike quote, this doesn't escape special characters.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - str: A variadic list of values to quote
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The single-quoted values joined with spaces
|
||||||
|
func squote(str ...any) string {
|
||||||
|
out := make([]string, 0, len(str))
|
||||||
|
for _, s := range str {
|
||||||
|
if s != nil {
|
||||||
|
out = append(out, fmt.Sprintf("'%v'", s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// cat concatenates all non-nil values into a single string.
|
||||||
|
// Nil values are removed before concatenation.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: A variadic list of values to concatenate
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The concatenated string
|
||||||
|
func cat(v ...any) string {
|
||||||
|
v = removeNilElements(v)
|
||||||
|
r := strings.TrimSpace(strings.Repeat("%v ", len(v)))
|
||||||
|
return fmt.Sprintf(r, v...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// indent adds a specified number of spaces at the beginning of each line in a string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - spaces: The number of spaces to add
|
||||||
|
// - v: The string to indent
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The indented string
|
||||||
|
func indent(spaces int, v string) string {
|
||||||
|
pad := strings.Repeat(" ", spaces)
|
||||||
|
return pad + strings.Replace(v, "\n", "\n"+pad, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nindent adds a newline followed by an indented string.
|
||||||
|
// It's a shorthand for "\n" + indent(spaces, v).
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - spaces: The number of spaces to add
|
||||||
|
// - v: The string to indent
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A newline followed by the indented string
|
||||||
|
func nindent(spaces int, v string) string {
|
||||||
|
return "\n" + indent(spaces, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace replaces all occurrences of a substring with another substring.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - old: The substring to replace
|
||||||
|
// - new: The replacement substring
|
||||||
|
// - src: The source string
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The resulting string after all replacements
|
||||||
|
func replace(old, new, src string) string {
|
||||||
|
return strings.Replace(src, old, new, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plural returns the singular or plural form of a word based on the count.
|
||||||
|
// If count is 1, it returns the singular form, otherwise it returns the plural form.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - one: The singular form of the word
|
||||||
|
// - many: The plural form of the word
|
||||||
|
// - count: The count to determine which form to use
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: Either the singular or plural form based on the count
|
||||||
|
func plural(one, many string, count int) string {
|
||||||
|
if count == 1 {
|
||||||
|
return one
|
||||||
|
}
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
|
||||||
|
// strslice converts a value to a slice of strings.
|
||||||
|
// It handles various input types:
|
||||||
|
// - []string: returned as is
|
||||||
|
// - []any: converted to []string, skipping nil values
|
||||||
|
// - arrays and slices: converted to []string, skipping nil values
|
||||||
|
// - nil: returns an empty slice
|
||||||
|
// - anything else: returns a single-element slice with the string representation
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to a string slice
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice of strings
|
||||||
|
func strslice(v any) []string {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
b := make([]string, 0, len(v))
|
||||||
|
for _, s := range v {
|
||||||
|
if s != nil {
|
||||||
|
b = append(b, strval(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
default:
|
||||||
|
val := reflect.ValueOf(v)
|
||||||
|
switch val.Kind() {
|
||||||
|
case reflect.Array, reflect.Slice:
|
||||||
|
l := val.Len()
|
||||||
|
b := make([]string, 0, l)
|
||||||
|
for i := 0; i < l; i++ {
|
||||||
|
value := val.Index(i).Interface()
|
||||||
|
if value != nil {
|
||||||
|
b = append(b, strval(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
default:
|
||||||
|
if v == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []string{strval(v)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeNilElements creates a new slice with all nil elements removed.
|
||||||
|
// This is a helper function used by other functions like cat.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The slice to process
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []any: A new slice with all nil elements removed
|
||||||
|
func removeNilElements(v []any) []any {
|
||||||
|
newSlice := make([]any, 0, len(v))
|
||||||
|
for _, i := range v {
|
||||||
|
if i != nil {
|
||||||
|
newSlice = append(newSlice, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newSlice
|
||||||
|
}
|
||||||
|
|
||||||
|
// strval converts any value to a string.
|
||||||
|
// It handles various types:
|
||||||
|
// - string: returned as is
|
||||||
|
// - []byte: converted to string
|
||||||
|
// - error: returns the error message
|
||||||
|
// - fmt.Stringer: calls the String() method
|
||||||
|
// - anything else: uses fmt.Sprintf("%v", v)
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - v: The value to convert to a string
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The string representation of the value
|
||||||
|
func strval(v any) string {
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case []byte:
|
||||||
|
return string(v)
|
||||||
|
case error:
|
||||||
|
return v.Error()
|
||||||
|
case fmt.Stringer:
|
||||||
|
return v.String()
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trunc truncates a string to a specified length.
|
||||||
|
// If c is positive, it returns the first c characters.
|
||||||
|
// If c is negative, it returns the last |c| characters.
|
||||||
|
// If the string is shorter than the requested length, it returns the original string.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - c: The number of characters to keep (positive from start, negative from end)
|
||||||
|
// - s: The string to truncate
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The truncated string
|
||||||
|
func trunc(c int, s string) string {
|
||||||
|
if c < 0 && len(s)+c > 0 {
|
||||||
|
return s[len(s)+c:]
|
||||||
|
}
|
||||||
|
if c >= 0 && len(s) > c {
|
||||||
|
return s[:c]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// title converts a string to title case.
|
||||||
|
// This uses the English language rules for capitalization.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - s: The string to convert
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The string in title case
|
||||||
|
func title(s string) string {
|
||||||
|
return cases.Title(language.English).String(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// join concatenates the elements of a slice with a separator.
|
||||||
|
// The input is first converted to a string slice using strslice.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sep: The separator to use between elements
|
||||||
|
// - v: The value to join (will be converted to a string slice)
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The joined string
|
||||||
|
func join(sep string, v any) string {
|
||||||
|
return strings.Join(strslice(v), sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// split splits a string by a separator and returns a map.
|
||||||
|
// The keys in the map are "_0", "_1", etc., corresponding to the position of each part.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sep: The separator to split on
|
||||||
|
// - orig: The string to split
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts
|
||||||
|
func split(sep, orig string) map[string]string {
|
||||||
|
parts := strings.Split(orig, sep)
|
||||||
|
res := make(map[string]string, len(parts))
|
||||||
|
for i, v := range parts {
|
||||||
|
res["_"+strconv.Itoa(i)] = v
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitList splits a string by a separator and returns a slice.
|
||||||
|
// This is a simple wrapper around strings.Split.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sep: The separator to split on
|
||||||
|
// - orig: The string to split
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - []string: A slice containing the split parts
|
||||||
|
func splitList(sep, orig string) []string {
|
||||||
|
return strings.Split(orig, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitn splits a string by a separator with a limit and returns a map.
|
||||||
|
// The keys in the map are "_0", "_1", etc., corresponding to the position of each part.
|
||||||
|
// It will split the string into at most n parts.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - sep: The separator to split on
|
||||||
|
// - n: The maximum number of parts to return
|
||||||
|
// - orig: The string to split
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - map[string]string: A map with keys "_0", "_1", etc. and values being the split parts
|
||||||
|
func splitn(sep string, n int, orig string) map[string]string {
|
||||||
|
parts := strings.SplitN(orig, sep, n)
|
||||||
|
res := make(map[string]string, len(parts))
|
||||||
|
for i, v := range parts {
|
||||||
|
res["_"+strconv.Itoa(i)] = v
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// substring creates a substring of the given string.
|
||||||
|
// It extracts a portion of a string based on start and end indices.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - start: The starting index (inclusive)
|
||||||
|
// - end: The ending index (exclusive)
|
||||||
|
// - s: The source string
|
||||||
|
//
|
||||||
|
// Behavior:
|
||||||
|
// - If start < 0, returns s[:end]
|
||||||
|
// - If start >= 0 and end < 0 or end > len(s), returns s[start:]
|
||||||
|
// - Otherwise, returns s[start:end]
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The extracted substring
|
||||||
|
func substring(start, end int, s string) string {
|
||||||
|
if start < 0 {
|
||||||
|
return s[:end]
|
||||||
|
}
|
||||||
|
if end < 0 || end > len(s) {
|
||||||
|
return s[start:]
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeat creates a new string by repeating the input string a specified number of times.
|
||||||
|
// It has safety limits to prevent excessive memory usage or infinite loops.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - count: The number of times to repeat the string
|
||||||
|
// - str: The string to repeat
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The repeated string
|
||||||
|
//
|
||||||
|
// Panics:
|
||||||
|
// - If count exceeds loopExecutionLimit
|
||||||
|
// - If the resulting string length would exceed stringLengthLimit
|
||||||
|
func repeat(count int, str string) string {
|
||||||
|
if count > loopExecutionLimit {
|
||||||
|
panic(fmt.Sprintf("repeat count %d exceeds limit of %d", count, loopExecutionLimit))
|
||||||
|
} else if count*len(str) >= stringLengthLimit {
|
||||||
|
panic(fmt.Sprintf("repeat count %d with string length %d exceeds limit of %d", count, len(str), stringLengthLimit))
|
||||||
|
}
|
||||||
|
return strings.Repeat(str, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimAll removes all leading and trailing characters contained in the cutset.
|
||||||
|
// Note that the parameter order is reversed from the standard strings.Trim function.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The cutset of characters to remove
|
||||||
|
// - b: The string to trim
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The trimmed string
|
||||||
|
func trimAll(a, b string) string {
|
||||||
|
return strings.Trim(b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimPrefix removes the specified prefix from a string.
|
||||||
|
// If the string doesn't start with the prefix, it returns the original string.
|
||||||
|
// Note that the parameter order is reversed from the standard strings.TrimPrefix function.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The prefix to remove
|
||||||
|
// - b: The string to trim
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The string with the prefix removed, or the original string if it doesn't start with the prefix
|
||||||
|
func trimPrefix(a, b string) string {
|
||||||
|
return strings.TrimPrefix(b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimSuffix removes the specified suffix from a string.
|
||||||
|
// If the string doesn't end with the suffix, it returns the original string.
|
||||||
|
// Note that the parameter order is reversed from the standard strings.TrimSuffix function.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - a: The suffix to remove
|
||||||
|
// - b: The string to trim
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The string with the suffix removed, or the original string if it doesn't end with the suffix
|
||||||
|
func trimSuffix(a, b string) string {
|
||||||
|
return strings.TrimSuffix(b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains checks if a string contains a substring.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - substr: The substring to search for
|
||||||
|
// - str: The string to search in
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if str contains substr, false otherwise
|
||||||
|
func contains(substr string, str string) bool {
|
||||||
|
return strings.Contains(str, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPrefix checks if a string starts with a specified prefix.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - substr: The prefix to check for
|
||||||
|
// - str: The string to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if str starts with substr, false otherwise
|
||||||
|
func hasPrefix(substr string, str string) bool {
|
||||||
|
return strings.HasPrefix(str, substr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSuffix checks if a string ends with a specified suffix.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - substr: The suffix to check for
|
||||||
|
// - str: The string to check
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - bool: True if str ends with substr, false otherwise
|
||||||
|
func hasSuffix(substr string, str string) bool {
|
||||||
|
return strings.HasSuffix(str, substr)
|
||||||
|
}
|
||||||
233
util/sprig/strings_test.go
Normal file
233
util/sprig/strings_test.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubstr(t *testing.T) {
|
||||||
|
tpl := `{{"fooo" | substr 0 3 }}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubstr_shorterString(t *testing.T) {
|
||||||
|
tpl := `{{"foo" | substr 0 10 }}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrunc(t *testing.T) {
|
||||||
|
tpl := `{{ "foooooo" | trunc 3 }}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ "baaaaaar" | trunc -3 }}`
|
||||||
|
if err := runt(tpl, "aar"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ "baaaaaar" | trunc -999 }}`
|
||||||
|
if err := runt(tpl, "baaaaaar"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ "baaaaaz" | trunc 0 }}`
|
||||||
|
if err := runt(tpl, ""); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuote(t *testing.T) {
|
||||||
|
tpl := `{{quote "a" "b" "c"}}`
|
||||||
|
if err := runt(tpl, `"a" "b" "c"`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{quote "\"a\"" "b" "c"}}`
|
||||||
|
if err := runt(tpl, `"\"a\"" "b" "c"`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{quote 1 2 3 }}`
|
||||||
|
if err := runt(tpl, `"1" "2" "3"`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ .value | quote }}`
|
||||||
|
values := map[string]any{"value": nil}
|
||||||
|
if err := runtv(tpl, ``, values); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSquote(t *testing.T) {
|
||||||
|
tpl := `{{squote "a" "b" "c"}}`
|
||||||
|
if err := runt(tpl, `'a' 'b' 'c'`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{squote 1 2 3 }}`
|
||||||
|
if err := runt(tpl, `'1' '2' '3'`); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ .value | squote }}`
|
||||||
|
values := map[string]any{"value": nil}
|
||||||
|
if err := runtv(tpl, ``, values); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContains(t *testing.T) {
|
||||||
|
// Mainly, we're just verifying the paramater order swap.
|
||||||
|
tests := []string{
|
||||||
|
`{{if contains "cat" "fair catch"}}1{{end}}`,
|
||||||
|
`{{if hasPrefix "cat" "catch"}}1{{end}}`,
|
||||||
|
`{{if hasSuffix "cat" "ducat"}}1{{end}}`,
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if err := runt(tt, "1"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrim(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
`{{trim " 5.00 "}}`,
|
||||||
|
`{{trimAll "$" "$5.00$"}}`,
|
||||||
|
`{{trimPrefix "$" "$5.00"}}`,
|
||||||
|
`{{trimSuffix "$" "5.00$"}}`,
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if err := runt(tt, "5.00"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplit(t *testing.T) {
|
||||||
|
tpl := `{{$v := "foo$bar$baz" | split "$"}}{{$v._0}}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitn(t *testing.T) {
|
||||||
|
tpl := `{{$v := "foo$bar$baz" | splitn "$" 2}}{{$v._0}}`
|
||||||
|
if err := runt(tpl, "foo"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToString(t *testing.T) {
|
||||||
|
tpl := `{{ toString 1 | kindOf }}`
|
||||||
|
assert.NoError(t, runt(tpl, "string"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToStrings(t *testing.T) {
|
||||||
|
tpl := `{{ $s := list 1 2 3 | toStrings }}{{ index $s 1 | kindOf }}`
|
||||||
|
assert.NoError(t, runt(tpl, "string"))
|
||||||
|
tpl = `{{ list 1 .value 2 | toStrings }}`
|
||||||
|
values := map[string]any{"value": nil}
|
||||||
|
if err := runtv(tpl, `[1 2]`, values); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoin(t *testing.T) {
|
||||||
|
assert.NoError(t, runt(`{{ tuple "a" "b" "c" | join "-" }}`, "a-b-c"))
|
||||||
|
assert.NoError(t, runt(`{{ tuple 1 2 3 | join "-" }}`, "1-2-3"))
|
||||||
|
assert.NoError(t, runtv(`{{ join "-" .V }}`, "a-b-c", map[string]any{"V": []string{"a", "b", "c"}}))
|
||||||
|
assert.NoError(t, runtv(`{{ join "-" .V }}`, "abc", map[string]any{"V": "abc"}))
|
||||||
|
assert.NoError(t, runtv(`{{ join "-" .V }}`, "1-2-3", map[string]any{"V": []int{1, 2, 3}}))
|
||||||
|
assert.NoError(t, runtv(`{{ join "-" .value }}`, "1-2", map[string]any{"value": []any{"1", nil, "2"}}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortAlpha(t *testing.T) {
|
||||||
|
// Named `append` in the function map
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ list "c" "a" "b" | sortAlpha | join "" }}`: "abc",
|
||||||
|
`{{ list 2 1 4 3 | sortAlpha | join "" }}`: "1234",
|
||||||
|
}
|
||||||
|
for tpl, expect := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestBase64EncodeDecode(t *testing.T) {
|
||||||
|
magicWord := "coffee"
|
||||||
|
expect := base64.StdEncoding.EncodeToString([]byte(magicWord))
|
||||||
|
|
||||||
|
if expect == magicWord {
|
||||||
|
t.Fatal("Encoder doesn't work.")
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl := `{{b64enc "coffee"}}`
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = fmt.Sprintf("{{b64dec %q}}", expect)
|
||||||
|
if err := runt(tpl, magicWord); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestBase32EncodeDecode(t *testing.T) {
|
||||||
|
magicWord := "coffee"
|
||||||
|
expect := base32.StdEncoding.EncodeToString([]byte(magicWord))
|
||||||
|
|
||||||
|
if expect == magicWord {
|
||||||
|
t.Fatal("Encoder doesn't work.")
|
||||||
|
}
|
||||||
|
|
||||||
|
tpl := `{{b32enc "coffee"}}`
|
||||||
|
if err := runt(tpl, expect); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = fmt.Sprintf("{{b32dec %q}}", expect)
|
||||||
|
if err := runt(tpl, magicWord); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCat(t *testing.T) {
|
||||||
|
tpl := `{{$b := "b"}}{{"c" | cat "a" $b}}`
|
||||||
|
if err := runt(tpl, "a b c"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{ .value | cat "a" "b"}}`
|
||||||
|
values := map[string]any{"value": nil}
|
||||||
|
if err := runtv(tpl, "a b", values); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndent(t *testing.T) {
|
||||||
|
tpl := `{{indent 4 "a\nb\nc"}}`
|
||||||
|
if err := runt(tpl, " a\n b\n c"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNindent(t *testing.T) {
|
||||||
|
tpl := `{{nindent 4 "a\nb\nc"}}`
|
||||||
|
if err := runt(tpl, "\n a\n b\n c"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplace(t *testing.T) {
|
||||||
|
tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
|
||||||
|
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlural(t *testing.T) {
|
||||||
|
tpl := `{{$num := len "two"}}{{$num}} {{$num | plural "1 char" "chars"}}`
|
||||||
|
if err := runt(tpl, "3 chars"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
tpl = `{{len "t" | plural "cheese" "%d chars"}}`
|
||||||
|
if err := runt(tpl, "cheese"); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
util/sprig/url.go
Normal file
65
util/sprig/url.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dictGetOrEmpty(dict map[string]any, key string) string {
|
||||||
|
value, ok := dict[key]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tp := reflect.TypeOf(value).Kind()
|
||||||
|
if tp != reflect.String {
|
||||||
|
panic(fmt.Sprintf("unable to parse %s key, must be of type string, but %s found", key, tp.String()))
|
||||||
|
}
|
||||||
|
return reflect.ValueOf(value).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parses given URL to return dict object
|
||||||
|
func urlParse(v string) map[string]any {
|
||||||
|
dict := map[string]any{}
|
||||||
|
parsedURL, err := url.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("unable to parse url: %s", err))
|
||||||
|
}
|
||||||
|
dict["scheme"] = parsedURL.Scheme
|
||||||
|
dict["host"] = parsedURL.Host
|
||||||
|
dict["hostname"] = parsedURL.Hostname()
|
||||||
|
dict["path"] = parsedURL.Path
|
||||||
|
dict["query"] = parsedURL.RawQuery
|
||||||
|
dict["opaque"] = parsedURL.Opaque
|
||||||
|
dict["fragment"] = parsedURL.Fragment
|
||||||
|
if parsedURL.User != nil {
|
||||||
|
dict["userinfo"] = parsedURL.User.String()
|
||||||
|
} else {
|
||||||
|
dict["userinfo"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict
|
||||||
|
}
|
||||||
|
|
||||||
|
// join given dict to URL string
|
||||||
|
func urlJoin(d map[string]any) string {
|
||||||
|
resURL := url.URL{
|
||||||
|
Scheme: dictGetOrEmpty(d, "scheme"),
|
||||||
|
Host: dictGetOrEmpty(d, "host"),
|
||||||
|
Path: dictGetOrEmpty(d, "path"),
|
||||||
|
RawQuery: dictGetOrEmpty(d, "query"),
|
||||||
|
Opaque: dictGetOrEmpty(d, "opaque"),
|
||||||
|
Fragment: dictGetOrEmpty(d, "fragment"),
|
||||||
|
}
|
||||||
|
userinfo := dictGetOrEmpty(d, "userinfo")
|
||||||
|
var user *url.Userinfo
|
||||||
|
if userinfo != "" {
|
||||||
|
tempURL, err := url.Parse(fmt.Sprintf("proto://%s@host", userinfo))
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("unable to parse userinfo in dict: %s", err))
|
||||||
|
}
|
||||||
|
user = tempURL.User
|
||||||
|
}
|
||||||
|
resURL.User = user
|
||||||
|
return resURL.String()
|
||||||
|
}
|
||||||
87
util/sprig/url_test.go
Normal file
87
util/sprig/url_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package sprig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var urlTests = map[string]map[string]any{
|
||||||
|
"proto://auth@host:80/path?query#fragment": {
|
||||||
|
"fragment": "fragment",
|
||||||
|
"host": "host:80",
|
||||||
|
"hostname": "host",
|
||||||
|
"opaque": "",
|
||||||
|
"path": "/path",
|
||||||
|
"query": "query",
|
||||||
|
"scheme": "proto",
|
||||||
|
"userinfo": "auth",
|
||||||
|
},
|
||||||
|
"proto://host:80/path": {
|
||||||
|
"fragment": "",
|
||||||
|
"host": "host:80",
|
||||||
|
"hostname": "host",
|
||||||
|
"opaque": "",
|
||||||
|
"path": "/path",
|
||||||
|
"query": "",
|
||||||
|
"scheme": "proto",
|
||||||
|
"userinfo": "",
|
||||||
|
},
|
||||||
|
"something": {
|
||||||
|
"fragment": "",
|
||||||
|
"host": "",
|
||||||
|
"hostname": "",
|
||||||
|
"opaque": "",
|
||||||
|
"path": "something",
|
||||||
|
"query": "",
|
||||||
|
"scheme": "",
|
||||||
|
"userinfo": "",
|
||||||
|
},
|
||||||
|
"proto://user:passwor%20d@host:80/path": {
|
||||||
|
"fragment": "",
|
||||||
|
"host": "host:80",
|
||||||
|
"hostname": "host",
|
||||||
|
"opaque": "",
|
||||||
|
"path": "/path",
|
||||||
|
"query": "",
|
||||||
|
"scheme": "proto",
|
||||||
|
"userinfo": "user:passwor%20d",
|
||||||
|
},
|
||||||
|
"proto://host:80/pa%20th?key=val%20ue": {
|
||||||
|
"fragment": "",
|
||||||
|
"host": "host:80",
|
||||||
|
"hostname": "host",
|
||||||
|
"opaque": "",
|
||||||
|
"path": "/pa th",
|
||||||
|
"query": "key=val%20ue",
|
||||||
|
"scheme": "proto",
|
||||||
|
"userinfo": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUrlParse(t *testing.T) {
|
||||||
|
// testing that function is exported and working properly
|
||||||
|
assert.NoError(t, runt(
|
||||||
|
`{{ index ( urlParse "proto://auth@host:80/path?query#fragment" ) "host" }}`,
|
||||||
|
"host:80"))
|
||||||
|
|
||||||
|
// testing scenarios
|
||||||
|
for url, expected := range urlTests {
|
||||||
|
assert.EqualValues(t, expected, urlParse(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUrlJoin(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
`{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "query" "query" "scheme" "proto") }}`: "proto://host:80/path?query#fragment",
|
||||||
|
`{{ urlJoin (dict "fragment" "fragment" "host" "host:80" "path" "/path" "scheme" "proto" "userinfo" "ASDJKJSD") }}`: "proto://ASDJKJSD@host:80/path#fragment",
|
||||||
|
}
|
||||||
|
for tpl, expected := range tests {
|
||||||
|
assert.NoError(t, runt(tpl, expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
for expected, urlMap := range urlTests {
|
||||||
|
assert.EqualValues(t, expected, urlJoin(urlMap))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ErrWriteTimeout is returned when a write timed out
|
// ErrWriteTimeout is returned when a write timed out
|
||||||
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
|
var ErrWriteTimeout = errors.New("write operation failed due to timeout")
|
||||||
|
|
||||||
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
||||||
type TimeoutWriter struct {
|
type TimeoutWriter struct {
|
||||||
@@ -28,7 +28,7 @@ func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
|||||||
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
||||||
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||||
if time.Since(tw.start) > tw.timeout {
|
if time.Since(tw.start) > tw.timeout {
|
||||||
return 0, errors.New("write operation failed due to timeout since creation")
|
return 0, ErrWriteTimeout
|
||||||
}
|
}
|
||||||
return tw.writer.Write(p)
|
return tw.writer.Write(p)
|
||||||
}
|
}
|
||||||
|
|||||||
12
util/util.go
12
util/util.go
@@ -120,6 +120,18 @@ func Filter[T any](slice []T, f func(T) bool) []T {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find returns the first element in the slice that satisfies the given function, and a boolean indicating
|
||||||
|
// whether such an element was found. If no element is found, it returns the zero value of T and false.
|
||||||
|
func Find[T any](slice []T, f func(T) bool) (T, bool) {
|
||||||
|
for _, v := range slice {
|
||||||
|
if f(v) {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
func RandomString(length int) string {
|
func RandomString(length int) string {
|
||||||
return RandomStringPrefix("", length)
|
return RandomStringPrefix("", length)
|
||||||
|
|||||||
555
web/package-lock.json
generated
555
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -212,6 +212,7 @@
|
|||||||
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
|
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
|
||||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||||
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
"account_basics_phone_numbers_dialog_channel_call": "Call",
|
||||||
|
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
@@ -291,6 +292,7 @@
|
|||||||
"account_tokens_table_current_session": "Current browser session",
|
"account_tokens_table_current_session": "Current browser session",
|
||||||
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
||||||
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
||||||
|
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Cannot edit or delete provisioned token",
|
||||||
"account_tokens_table_create_token_button": "Create access token",
|
"account_tokens_table_create_token_button": "Create access token",
|
||||||
"account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
|
"account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
|
||||||
"account_tokens_dialog_title_create": "Create access token",
|
"account_tokens_dialog_title_create": "Create access token",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"nav_button_documentation": "ஆவணப்படுத்துதல்",
|
"nav_button_documentation": "ஆவணப்படுத்துதல்",
|
||||||
"nav_button_publish_message": "அறிவிப்பை வெளியிடுங்கள்",
|
"nav_button_publish_message": "அறிவிப்பை வெளியிடுங்கள்",
|
||||||
"alert_not_supported_description": "உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை",
|
"alert_not_supported_description": "உங்கள் உலாவியில் அறிவிப்புகள் ஆதரிக்கப்படவில்லை",
|
||||||
"alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது <mdnlink> அறிவிப்புகள் பநிஇ </mdnlink> இன் வரம்பு.",
|
"alert_not_supported_context_description": "அறிவிப்புகள் HTTP களில் மட்டுமே ஆதரிக்கப்படுகின்றன. இது<mdnLink>அறிவிப்புகள் பநிஇ</mdnLink> இன் வரம்பு.",
|
||||||
"notifications_list": "அறிவிப்புகள் பட்டியல்",
|
"notifications_list": "அறிவிப்புகள் பட்டியல்",
|
||||||
"notifications_delete": "நீக்கு",
|
"notifications_delete": "நீக்கு",
|
||||||
"notifications_copied_to_clipboard": "இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது",
|
"notifications_copied_to_clipboard": "இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"publish_dialog_chip_email_label": "மின்னஞ்சலுக்கு அனுப்பவும்",
|
"publish_dialog_chip_email_label": "மின்னஞ்சலுக்கு அனுப்பவும்",
|
||||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை",
|
"publish_dialog_chip_call_no_verified_numbers_tooltip": "சரிபார்க்கப்பட்ட தொலைபேசி எண்கள் இல்லை",
|
||||||
"publish_dialog_chip_attach_url_label": "முகவரி மூலம் கோப்பை இணைக்கவும்",
|
"publish_dialog_chip_attach_url_label": "முகவரி மூலம் கோப்பை இணைக்கவும்",
|
||||||
"publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <ock இணைப்பு> ஆவணங்கள் </டாக்ச் இணைப்பு> ஐப் பார்க்கவும்.",
|
"publish_dialog_details_examples_description": "எடுத்துக்காட்டுகள் மற்றும் அனைத்து அனுப்பும் அம்சங்களின் விரிவான விளக்கத்திற்கு, தயவுசெய்து <docsLink>ஆவணங்கள் </docsLink> ஐப் பார்க்கவும்.",
|
||||||
"publish_dialog_chip_attach_file_label": "உள்ளக கோப்பை இணைக்கவும்",
|
"publish_dialog_chip_attach_file_label": "உள்ளக கோப்பை இணைக்கவும்",
|
||||||
"publish_dialog_chip_delay_label": "நேரந்தவறுகை வழங்கல்",
|
"publish_dialog_chip_delay_label": "நேரந்தவறுகை வழங்கல்",
|
||||||
"publish_dialog_chip_topic_label": "தலைப்பை மாற்றவும்",
|
"publish_dialog_chip_topic_label": "தலைப்பை மாற்றவும்",
|
||||||
@@ -133,10 +133,10 @@
|
|||||||
"account_usage_cannot_create_portal_session": "பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை",
|
"account_usage_cannot_create_portal_session": "பட்டியலிடல் போர்ட்டலைத் திறக்க முடியவில்லை",
|
||||||
"account_delete_title": "கணக்கை நீக்கு",
|
"account_delete_title": "கணக்கை நீக்கு",
|
||||||
"account_delete_description": "உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்",
|
"account_delete_description": "உங்கள் கணக்கை நிரந்தரமாக நீக்கவும்",
|
||||||
"account_upgrade_dialog_cancel_warning": "இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date} at இல் தரமிறக்குகிறது. அந்த தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாக சேமிக்கப்பட்ட செய்திகளும் நீக்கப்படும் </strong>.",
|
"account_upgrade_dialog_cancel_warning": "இது <strong> உங்கள் சந்தாவை ரத்துசெய்யும் </strong>, மேலும் உங்கள் கணக்கை {{date}} இல் தரமிறக்குகிறது. அந்தத் தேதியில், தலைப்பு முன்பதிவு மற்றும் சேவையகத்தில் தற்காலிகமாகச் சேமிக்கப்பட்ட செய்திகளும் <strong>நீக்கப்படும் </strong>.",
|
||||||
"account_upgrade_dialog_proration_info": "<strong> புரோரேசன் </strong>: கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு <strong> உடனடியாக கட்டணம் வசூலிக்கப்படும் </strong>. குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.",
|
"account_upgrade_dialog_proration_info": "<strong> புரோரேசன் </strong>: கட்டணத் திட்டங்களுக்கு இடையில் மேம்படுத்தும்போது, விலை வேறுபாடு <strong> உடனடியாக கட்டணம் வசூலிக்கப்படும் </strong>. குறைந்த அடுக்குக்கு தரமிறக்கும்போது, எதிர்கால பட்டியலிடல் காலங்களுக்கு செலுத்த இருப்பு பயன்படுத்தப்படும்.",
|
||||||
"account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.",
|
"account_upgrade_dialog_reservations_warning_one": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தது ஒரு முன்பதிவை நீக்கு </strong>. <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
|
||||||
"account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கை விட குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <இணைப்பு> அமைப்புகள் </இணைப்பு> இல் முன்பதிவுகளை அகற்றலாம்.",
|
"account_upgrade_dialog_reservations_warning_other": "தேர்ந்தெடுக்கப்பட்ட அடுக்கு உங்கள் தற்போதைய அடுக்கைவிடக் குறைவான ஒதுக்கப்பட்ட தலைப்புகளை அனுமதிக்கிறது. உங்கள் அடுக்கை மாற்றுவதற்கு முன், <strong> தயவுசெய்து குறைந்தபட்சம் {{count}} முன்பதிவு </strong> ஐ நீக்கவும். <Link>அமைப்புகள்</Link> இல் முன்பதிவுகளை அகற்றலாம்.",
|
||||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்",
|
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} ஒதுக்கப்பட்ட தலைப்புகள்",
|
||||||
"account_upgrade_dialog_tier_features_no_reservations": "ஒதுக்கப்பட்ட தலைப்புகள் இல்லை",
|
"account_upgrade_dialog_tier_features_no_reservations": "ஒதுக்கப்பட்ட தலைப்புகள் இல்லை",
|
||||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} நாள்தோறும் செய்தி",
|
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} நாள்தோறும் செய்தி",
|
||||||
@@ -153,14 +153,14 @@
|
|||||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.",
|
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ஆண்டுதோறும் கட்டணம் செலுத்தப்படுகிறது. {{save}} சேமி.",
|
||||||
"account_upgrade_dialog_tier_selected_label": "தேர்ந்தெடுக்கப்பட்டது",
|
"account_upgrade_dialog_tier_selected_label": "தேர்ந்தெடுக்கப்பட்டது",
|
||||||
"account_upgrade_dialog_tier_current_label": "மின்னோட்ட்ம், ஓட்டம்",
|
"account_upgrade_dialog_tier_current_label": "மின்னோட்ட்ம், ஓட்டம்",
|
||||||
"account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <இணைப்பு> எங்களை தொடர்பு கொள்ளவும் </இணைப்பு> நேரடியாக.",
|
"account_upgrade_dialog_billing_contact_email": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து <Link>எங்களைத் தொடர்பு கொள்ளவும் </Link>நேரடியாக.",
|
||||||
"account_upgrade_dialog_button_cancel": "ரத்துசெய்",
|
"account_upgrade_dialog_button_cancel": "ரத்துசெய்",
|
||||||
"account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <இணைப்பு> வலைத்தளம் </இணைப்பு> ஐப் பார்க்கவும்.",
|
"account_upgrade_dialog_billing_contact_website": "பட்டியலிடல் கேள்விகளுக்கு, தயவுசெய்து எங்கள் <Link>வலைத்தளம்</Link> ஐப் பார்க்கவும்.",
|
||||||
"account_upgrade_dialog_button_redirect_signup": "இப்போது பதிவுபெறுக",
|
"account_upgrade_dialog_button_redirect_signup": "இப்போது பதிவுபெறுக",
|
||||||
"account_upgrade_dialog_button_pay_now": "இப்போது பணம் செலுத்தி குழுசேரவும்",
|
"account_upgrade_dialog_button_pay_now": "இப்போது பணம் செலுத்தி குழுசேரவும்",
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "சந்தாவை ரத்துசெய்",
|
"account_upgrade_dialog_button_cancel_subscription": "சந்தாவை ரத்துசெய்",
|
||||||
"account_tokens_title": "டோக்கன்களை அணுகவும்",
|
"account_tokens_title": "டோக்கன்களை அணுகவும்",
|
||||||
"account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும் போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <இணைப்பு> ஆவணங்கள் </இணைப்பு> ஐப் பாருங்கள்.",
|
"account_tokens_description": "NTFY பநிஇ வழியாக வெளியிடும் மற்றும் சந்தா செலுத்தும்போது அணுகல் டோக்கன்களைப் பயன்படுத்தவும், எனவே உங்கள் கணக்கு நற்சான்றிதழ்களை அனுப்ப வேண்டியதில்லை. மேலும் அறிய <Link> ஆவணங்கள்</Link> ஐப் பாருங்கள்.",
|
||||||
"account_upgrade_dialog_button_update_subscription": "சந்தாவைப் புதுப்பிக்கவும்",
|
"account_upgrade_dialog_button_update_subscription": "சந்தாவைப் புதுப்பிக்கவும்",
|
||||||
"account_tokens_table_token_header": "கிள்ளாக்கு",
|
"account_tokens_table_token_header": "கிள்ளாக்கு",
|
||||||
"account_tokens_table_label_header": "சிட்டை",
|
"account_tokens_table_label_header": "சிட்டை",
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
"prefs_notifications_web_push_title": "பின்னணி அறிவிப்புகள்",
|
"prefs_notifications_web_push_title": "பின்னணி அறிவிப்புகள்",
|
||||||
"prefs_notifications_web_push_enabled_description": "வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)",
|
"prefs_notifications_web_push_enabled_description": "வலை பயன்பாடு இயங்காதபோது கூட அறிவிப்புகள் பெறப்படுகின்றன (வலை புச் வழியாக)",
|
||||||
"prefs_notifications_web_push_disabled_description": "வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)",
|
"prefs_notifications_web_push_disabled_description": "வலை பயன்பாடு இயங்கும்போது அறிவிப்பு பெறப்படுகிறது (வெப்சாக்கெட் வழியாக)",
|
||||||
"prefs_notifications_web_push_enabled": "{{server} க்கு க்கு இயக்கப்பட்டது",
|
"prefs_notifications_web_push_enabled": "{{server}} க்கு இயக்கப்பட்டது",
|
||||||
"prefs_notifications_web_push_disabled": "முடக்கப்பட்டது",
|
"prefs_notifications_web_push_disabled": "முடக்கப்பட்டது",
|
||||||
"prefs_users_title": "பயனர்களை நிர்வகிக்கவும்",
|
"prefs_users_title": "பயனர்களை நிர்வகிக்கவும்",
|
||||||
"prefs_users_description": "உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.",
|
"prefs_users_description": "உங்கள் பாதுகாக்கப்பட்ட தலைப்புகளுக்கு பயனர்களை இங்கே சேர்க்கவும்/அகற்றவும். பயனர்பெயர் மற்றும் கடவுச்சொல் உலாவியின் உள்ளக சேமிப்பகத்தில் சேமிக்கப்பட்டுள்ளன என்பதை நினைவில் கொள்க.",
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
"priority_max": "அதிகபட்சம்",
|
"priority_max": "அதிகபட்சம்",
|
||||||
"priority_default": "இயல்புநிலை",
|
"priority_default": "இயல்புநிலை",
|
||||||
"error_boundary_title": "ஓ, NTFY செயலிழந்தது",
|
"error_boundary_title": "ஓ, NTFY செயலிழந்தது",
|
||||||
"error_boundary_description": "இது வெளிப்படையாக நடக்கக்கூடாது. இதைப் பற்றி மிகவும் வருந்துகிறேன். .",
|
"error_boundary_description": "இது நிச்சயமாக நடக்கக் கூடாது. இதுகுறித்து மிகவும் வருந்துகிறேன்.<br/>உங்களிடம் ஒரு நிமிடம் இருந்தால், தயவுசெய்து <githubLink>இதை GitHub இல் புகாரளிக்கவும்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> வழியாக எங்களுக்குத் தெரியப்படுத்தவும்.",
|
||||||
"error_boundary_button_copy_stack_trace": "அடுக்கு சுவடு நகலெடுக்கவும்",
|
"error_boundary_button_copy_stack_trace": "அடுக்கு சுவடு நகலெடுக்கவும்",
|
||||||
"error_boundary_button_reload_ntfy": "Ntfy ஐ மீண்டும் ஏற்றவும்",
|
"error_boundary_button_reload_ntfy": "Ntfy ஐ மீண்டும் ஏற்றவும்",
|
||||||
"error_boundary_stack_trace": "ச்டாக் சுவடு",
|
"error_boundary_stack_trace": "ச்டாக் சுவடு",
|
||||||
@@ -349,7 +349,7 @@
|
|||||||
"notifications_no_subscriptions_title": "உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.",
|
"notifications_no_subscriptions_title": "உங்களிடம் இன்னும் சந்தாக்கள் இல்லை என்று தெரிகிறது.",
|
||||||
"notifications_no_subscriptions_description": "ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \"{{linktext}}\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.",
|
"notifications_no_subscriptions_description": "ஒரு தலைப்பை உருவாக்க அல்லது குழுசேர \"{{linktext}}\" இணைப்பைக் சொடுக்கு செய்க. அதன்பிறகு, நீங்கள் புட் அல்லது இடுகை வழியாக செய்திகளை அனுப்பலாம், மேலும் நீங்கள் இங்கே அறிவிப்புகளைப் பெறுவீர்கள்.",
|
||||||
"notifications_example": "எடுத்துக்காட்டு",
|
"notifications_example": "எடுத்துக்காட்டு",
|
||||||
"notifications_more_details": "மேலும் தகவலுக்கு, </webititeLink> வலைத்தளம் </websiteLink> அல்லது <ockslink> ஆவணங்கள் </docslink> ஐப் பாருங்கள்.",
|
"notifications_more_details": "மேலும் தகவலுக்கு, <websiteLink>வலைத்தளம் </websiteLink> அல்லது <docsLink> ஆவணங்கள் </docsLink> ஐப் பாருங்கள்.",
|
||||||
"display_name_dialog_title": "காட்சி பெயரை மாற்றவும்",
|
"display_name_dialog_title": "காட்சி பெயரை மாற்றவும்",
|
||||||
"display_name_dialog_description": "சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.",
|
"display_name_dialog_description": "சந்தா பட்டியலில் காட்டப்படும் தலைப்புக்கு மாற்று பெயரை அமைக்கவும். சிக்கலான பெயர்களைக் கொண்ட தலைப்புகளை மிக எளிதாக அடையாளம் காண இது உதவுகிறது.",
|
||||||
"display_name_dialog_placeholder": "காட்சி பெயர்",
|
"display_name_dialog_placeholder": "காட்சி பெயர்",
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "{{discount}}% வரை சேமிக்கவும்",
|
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "{{discount}}% வரை சேமிக்கவும்",
|
||||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு",
|
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} முன்பதிவு செய்யப்பட்ட தலைப்பு",
|
||||||
"prefs_users_add_button": "பயனரைச் சேர்க்கவும்",
|
"prefs_users_add_button": "பயனரைச் சேர்க்கவும்",
|
||||||
"error_boundary_unsupported_indexeddb_description": "NTFY வலை பயன்பாட்டிற்கு செயல்பட குறியீட்டு தேவை, மற்றும் உங்கள் உலாவி தனிப்பட்ட உலாவல் பயன்முறையில் IndexEDDB ஐ ஆதரிக்காது. எப்படியிருந்தாலும் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்பாடு, ஏனென்றால் அனைத்தும் உலாவி சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இந்த அறிவிலிமையம் இதழில் </githublink> இல் <githublink> பற்றி நீங்கள் மேலும் படிக்கலாம் அல்லது <scordlink> டிச்கார்ட் </disordlink> அல்லது <agadgaglelink> மேட்ரிக்ச் </மேட்ரிக்ச்லிங்க்> இல் எங்களுடன் பேசலாம்.",
|
"error_boundary_unsupported_indexeddb_description": "ntfy வலை பயன்பாடு செயல்பட IndexedDB தேவை, மேலும் உங்கள் உலாவித் தனிப்பட்ட உலாவல் பயன்முறையில் IndexedDB ஐ ஆதரிக்காது.<br/><br/>இது துரதிர்ஷ்டவசமானது என்றாலும், ntfy வலை பயன்பாட்டைத் தனிப்பட்ட உலாவல் பயன்முறையில் பயன்படுத்துவது உண்மையில் அர்த்தமற்றது, ஏனெனில் அனைத்தும் உலாவிச் சேமிப்பகத்தில் சேமிக்கப்படுகின்றன. இதைப் பற்றி நீங்கள் <githubLink>இந்த GitHub சிக்கலில் மேலும் படிக்கலாம்</githubLink>, அல்லது <discordLink>Discord</discordLink> அல்லது <matrixLink>Matrix</matrixLink> இல் எங்களுடன் பேசலாம்.",
|
||||||
"web_push_subscription_expiring_title": "அறிவிப்புகள் இடைநிறுத்தப்படும்",
|
"web_push_subscription_expiring_title": "அறிவிப்புகள் இடைநிறுத்தப்படும்",
|
||||||
"web_push_subscription_expiring_body": "தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்",
|
"web_push_subscription_expiring_body": "தொடர்ந்து அறிவிப்புகளைப் பெற NTFY ஐத் திறக்கவும்",
|
||||||
"web_push_unknown_notification_title": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",
|
"web_push_unknown_notification_title": "சேவையகத்திலிருந்து அறியப்படாத அறிவிப்பு பெறப்பட்டது",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user