Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b531bc95ea | ||
|
|
da6f6f528c | ||
|
|
926d8b981b | ||
|
|
997923dd98 | ||
|
|
8131d0d883 | ||
|
|
a326dc034f | ||
|
|
736905c1e8 | ||
|
|
857d56c281 | ||
|
|
cef61b2c48 | ||
|
|
b483891bcb | ||
|
|
bb9bbdf736 | ||
|
|
9e1636a3f7 | ||
|
|
315326ffc0 | ||
|
|
65188b1f07 | ||
|
|
5fefcd1296 | ||
|
|
4c4a5725d8 | ||
|
|
ab2a686937 | ||
|
|
01bb0eeccd | ||
|
|
7cabc8bcec | ||
|
|
95f4e58ca0 | ||
|
|
692a1fa532 | ||
|
|
54ded9db9a | ||
|
|
db2b3a0dd8 | ||
|
|
0b4bcf573e | ||
|
|
f9a88f841e | ||
|
|
c4291cc23e | ||
|
|
fd0595b547 | ||
|
|
b59e18bed8 | ||
|
|
1956ffbf02 | ||
|
|
e647c68cb9 | ||
|
|
53dce3013d | ||
|
|
7615aa86ad | ||
|
|
07ef3e5656 | ||
|
|
2804acf0f5 | ||
|
|
498b632796 | ||
|
|
61e496fc4c | ||
|
|
83e74b014e | ||
|
|
364696e059 | ||
|
|
546c94ba98 | ||
|
|
203d739d38 | ||
|
|
89fd37bc2c | ||
|
|
7856ab5dfb | ||
|
|
773be05bb1 | ||
|
|
c1a6e9a429 | ||
|
|
c9a0a40805 | ||
|
|
439049624c | ||
|
|
a6078037c0 | ||
|
|
9c5c17441f | ||
|
|
b56d250708 | ||
|
|
a714fe2618 | ||
|
|
e863872d0e | ||
|
|
77406f3496 | ||
|
|
157add4835 | ||
|
|
50f3563477 | ||
|
|
e08f3670d1 | ||
|
|
4f6f45a9c0 | ||
|
|
3de04b27ab | ||
|
|
ec1f97b726 | ||
|
|
569d89e8f8 | ||
|
|
f2f146e39b | ||
|
|
18d08298cc | ||
|
|
ebb386af58 | ||
|
|
b105ed6727 | ||
|
|
1916376f8d | ||
|
|
965110b2c3 | ||
|
|
c8ac104043 | ||
|
|
f6bd0a8d51 | ||
|
|
e39498702d | ||
|
|
9b97067b10 | ||
|
|
f72f0d800f | ||
|
|
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 | ||
|
|
03aeb707f2 |
@@ -1,76 +1,70 @@
|
||||
version: 2
|
||||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
- go mod tidy
|
||||
builds:
|
||||
-
|
||||
id: ntfy_linux_amd64
|
||||
- id: ntfy_linux_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
tags: [sqlite_omit_load_extension,osusergo,netgo]
|
||||
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||
ldflags:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [amd64]
|
||||
-
|
||||
id: ntfy_linux_armv6
|
||||
goos: [ linux ]
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_linux_armv6
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- 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:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [6]
|
||||
-
|
||||
id: ntfy_linux_armv7
|
||||
goos: [ linux ]
|
||||
goarch: [ arm ]
|
||||
goarm: [ 6 ]
|
||||
- id: ntfy_linux_armv7
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- 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:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm]
|
||||
goarm: [7]
|
||||
-
|
||||
id: ntfy_linux_arm64
|
||||
goos: [ linux ]
|
||||
goarch: [ arm ]
|
||||
goarm: [ 7 ]
|
||||
- id: ntfy_linux_arm64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- 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:
|
||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [linux]
|
||||
goarch: [arm64]
|
||||
-
|
||||
id: ntfy_windows_amd64
|
||||
goos: [ linux ]
|
||||
goarch: [ arm64 ]
|
||||
- id: ntfy_windows_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
tags: [ noserver ] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [windows]
|
||||
goarch: [amd64]
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
goos: [ windows ]
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [noserver] # don't include server files
|
||||
tags: [ noserver ] # don't include server files
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [darwin]
|
||||
goarch: [amd64, arm64] # will be combined to "universal binary" (see below)
|
||||
goos: [ darwin ]
|
||||
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
|
||||
nfpms:
|
||||
-
|
||||
package_name: ntfy
|
||||
- package_name: ntfy
|
||||
homepage: https://heckel.io/ntfy
|
||||
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
description: Simple pub-sub notification service
|
||||
@@ -106,9 +100,8 @@ nfpms:
|
||||
preremove: "scripts/prerm.sh"
|
||||
postremove: "scripts/postrm.sh"
|
||||
archives:
|
||||
-
|
||||
id: ntfy_linux
|
||||
builds:
|
||||
- id: ntfy_linux
|
||||
ids:
|
||||
- ntfy_linux_amd64
|
||||
- ntfy_linux_armv6
|
||||
- ntfy_linux_armv7
|
||||
@@ -122,19 +115,17 @@ archives:
|
||||
- client/client.yml
|
||||
- client/ntfy-client.service
|
||||
- client/user/ntfy-client.service
|
||||
-
|
||||
id: ntfy_windows
|
||||
builds:
|
||||
- id: ntfy_windows
|
||||
ids:
|
||||
- ntfy_windows_amd64
|
||||
format: zip
|
||||
formats: [ zip ]
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
- client/client.yml
|
||||
-
|
||||
id: ntfy_darwin
|
||||
builds:
|
||||
- id: ntfy_darwin
|
||||
ids:
|
||||
- ntfy_darwin_all
|
||||
wrap_in_directory: true
|
||||
files:
|
||||
@@ -142,14 +133,13 @@ archives:
|
||||
- README.md
|
||||
- client/client.yml
|
||||
universal_binaries:
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
- id: ntfy_darwin_all
|
||||
replace: true
|
||||
name_template: ntfy
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ .Tag }}-next"
|
||||
version_template: "{{ .Tag }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
|
||||
6
Makefile
6
Makefile
@@ -220,7 +220,7 @@ cli-deps-static-sites:
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
cli-deps-all:
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
go install github.com/goreleaser/goreleaser/v2@latest
|
||||
|
||||
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; }
|
||||
@@ -232,7 +232,7 @@ cli-deps-update:
|
||||
go get -u
|
||||
go install honnef.co/go/tools/cmd/staticcheck@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:
|
||||
cat dist/config.yaml
|
||||
@@ -301,7 +301,7 @@ release: clean cli-deps release-checks docs web check
|
||||
goreleaser release --clean
|
||||
|
||||
release-snapshot: clean cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --clean
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
|
||||
16
README.md
16
README.md
@@ -1,3 +1,16 @@
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/ntfy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents.](https://go.warp.dev/ntfy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/ntfy)<br>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||

|
||||
|
||||
# ntfy.sh | Send push notifications to your phone or desktop via PUT/POST
|
||||
@@ -67,6 +80,8 @@ Thank you to our commercial sponsors, who help keep the service running and the
|
||||
|
||||
<a href="https://www.magicbell.com/?utm_source=ntfy"><img src="assets/sponsors/magicbell.png" width="180px"></a>
|
||||
|
||||
<a href="https://go.warp.dev/ntfy"><img src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png" width="160px"></a>
|
||||
|
||||
And a big fat **Thank You** to the individuals who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||
|
||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||
@@ -253,3 +268,4 @@ Third-party libraries and resources:
|
||||
* [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)
|
||||
* [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
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# default-command:
|
||||
|
||||
# Subscriptions to topics and their actions. This option is primarily used by the systemd service,
|
||||
# or if you cann "ntfy subscribe --from-config" directly.
|
||||
# or if you can "ntfy subscribe --from-config" directly.
|
||||
#
|
||||
# Example:
|
||||
# subscribe:
|
||||
|
||||
@@ -77,6 +77,12 @@ func WithMarkdown() PublishOption {
|
||||
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
|
||||
func WithFilename(filename string) PublishOption {
|
||||
return WithHeader("X-Filename", filename)
|
||||
|
||||
@@ -105,8 +105,10 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else if u.Role == user.RoleAdmin {
|
||||
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
|
||||
}
|
||||
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() {
|
||||
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() {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
@@ -138,7 +140,7 @@ func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
if err := manager.ResetAccess("", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
|
||||
fmt.Fprintln(c.App.Writer, "reset access for all users")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ func resetUserAccess(c *cli.Context, manager *user.Manager, username string) err
|
||||
if err := manager.ResetAccess(username, ""); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -154,7 +156,7 @@ func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string
|
||||
if err := manager.ResetAccess(username, topic); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -175,7 +177,7 @@ func showAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||
|
||||
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||
users, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -193,34 +195,42 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
||||
if u.Tier != nil {
|
||||
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 {
|
||||
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 {
|
||||
for _, grant := range grants {
|
||||
if grant.Allow.IsReadWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsRead() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||
} else if grant.Allow.IsWrite() {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||
grantProvisioned := ""
|
||||
if grant.Provisioned {
|
||||
grantProvisioned = " (server config)"
|
||||
}
|
||||
if grant.Permission.IsReadWrite() {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
access := manager.DefaultAccess()
|
||||
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() {
|
||||
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() {
|
||||
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 {
|
||||
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)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
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) {
|
||||
@@ -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, "everyone", "announcements", "read"))
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (role: admin, tier: none)
|
||||
- read-write access to all topics (admin role)
|
||||
@@ -41,7 +41,7 @@ user * (role: anonymous, tier: none)
|
||||
- read-only access to topic announcements
|
||||
- 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
|
||||
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: "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.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: "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"},
|
||||
@@ -98,6 +99,7 @@ func execPublish(c *cli.Context) error {
|
||||
actions := c.String("actions")
|
||||
attach := c.String("attach")
|
||||
markdown := c.Bool("markdown")
|
||||
template := c.String("template")
|
||||
filename := c.String("filename")
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
@@ -146,6 +148,9 @@ func execPublish(c *cli.Context) error {
|
||||
if markdown {
|
||||
options = append(options, client.WithMarkdown())
|
||||
}
|
||||
if template != "" {
|
||||
options = append(options, client.WithTemplate(template))
|
||||
}
|
||||
if filename != "" {
|
||||
options = append(options, client.WithFilename(filename))
|
||||
}
|
||||
|
||||
158
cmd/serve.go
158
cmd/serve.go
@@ -16,10 +16,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -29,13 +29,9 @@ func init() {
|
||||
commands = append(commands, cmdServe)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServerConfigFile = "/etc/ntfy/server.yml"
|
||||
)
|
||||
|
||||
var flagsServe = append(
|
||||
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: "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"}),
|
||||
@@ -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-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.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-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-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: "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"}),
|
||||
@@ -63,6 +63,7 @@ var flagsServe = append(
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "require-login", Aliases: []string{"require_login"}, EnvVars: []string{"NTFY_REQUIRE_LOGIN"}, Value: false, Usage: "all actions via the web app requires a login"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
@@ -157,16 +158,21 @@ func execServe(c *cli.Context) error {
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
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")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||
templateDir := c.String("template-dir")
|
||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||
managerIntervalStr := c.String("manager-interval")
|
||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
requireLogin := c.Bool("require-login")
|
||||
enableReservations := c.Bool("enable-reservations")
|
||||
upstreamBaseURL := c.String("upstream-base-url")
|
||||
upstreamAccessToken := c.String("upstream-access-token")
|
||||
@@ -275,6 +281,8 @@ func execServe(c *cli.Context) error {
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
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 == "") {
|
||||
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 {
|
||||
@@ -312,10 +320,14 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if authFile == "" && (enableSignup || enableLogin || requireLogin || enableReservations || stripeSecretKey != "") {
|
||||
return errors.New("cannot set enable-signup, enable-login, require-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||
} else if enableSignup && !enableLogin {
|
||||
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||
} else if requireLogin && !enableLogin {
|
||||
return errors.New("cannot set require-login 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 == "") {
|
||||
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 == "") {
|
||||
@@ -325,6 +337,8 @@ func execServe(c *cli.Context) error {
|
||||
if messageSizeLimit > 5*1024*1024 {
|
||||
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 {
|
||||
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
|
||||
} else if behindProxy && proxyForwardedHeader == "" {
|
||||
@@ -346,11 +360,23 @@ func execServe(c *cli.Context) error {
|
||||
webRoot = "/" + webRoot
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
// Convert default auth permission, read provisioned users
|
||||
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||
if err != nil {
|
||||
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
|
||||
if listenHTTP == "-" {
|
||||
@@ -380,8 +406,7 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
payments.Setup(stripeSecretKey)
|
||||
}
|
||||
|
||||
// Add default forbidden topics
|
||||
@@ -406,10 +431,14 @@ func execServe(c *cli.Context) error {
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthStartupQueries = authStartupQueries
|
||||
conf.AuthDefault = authDefault
|
||||
conf.AuthUsers = authUsers
|
||||
conf.AuthAccess = authAccess
|
||||
conf.AuthTokens = authTokens
|
||||
conf.AttachmentCacheDir = attachmentCacheDir
|
||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.TemplateDir = templateDir
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.DisallowedTopics = disallowedTopics
|
||||
@@ -450,6 +479,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.RequireLogin = requireLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
@@ -518,6 +548,112 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
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 {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
|
||||
@@ -14,9 +14,461 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/test"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"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) {
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
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 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 fmt.Errorf("tier %s already exists", code)
|
||||
@@ -234,7 +234,7 @@ func execTierAdd(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier added\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func execTierChange(c *cli.Context) error {
|
||||
if err := manager.UpdateTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
||||
fmt.Fprintf(c.App.Writer, "tier updated\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func execTierDel(c *cli.Context) error {
|
||||
if err := manager.RemoveTier(code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
||||
fmt.Fprintf(c.App.Writer, "tier %s removed\n", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -359,16 +359,16 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||
if 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.ErrWriter, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- 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.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- 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.ErrWriter, "- 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.ErrWriter, "- 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, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.Writer, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.Writer, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.Writer, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
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)
|
||||
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.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")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "tier pro already exists", err.Error())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||
require.Contains(t, stdout.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stdout.String(), "- Name: Pro")
|
||||
require.Contains(t, stdout.String(), "- Message limit: 1234")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=2d",
|
||||
@@ -40,18 +40,18 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
"--stripe-yearly-price-id=price_992",
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.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(), "- Message limit: 999")
|
||||
require.Contains(t, stdout.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stdout.String(), "- Email limit: 91")
|
||||
require.Contains(t, stdout.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stdout.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stdout.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stdout.String(), "- Attachment total size limit: 10.0 GB")
|
||||
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.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 {
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -112,19 +121,19 @@ func execTokenAdd(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -141,7 +150,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -149,7 +158,7 @@ func execTokenDel(c *cli.Context) error {
|
||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -165,7 +174,7 @@ func execTokenList(c *cli.Context) error {
|
||||
var users []*user.User
|
||||
if 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)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -183,15 +192,15 @@ func execTokenList(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
} 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
|
||||
} else if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
var label, expires string
|
||||
var label, expires, provisioned string
|
||||
if t.Label != "" {
|
||||
label = fmt.Sprintf(" (%s)", t.Label)
|
||||
}
|
||||
@@ -200,11 +209,19 @@ func execTokenList(c *cli.Context) error {
|
||||
} else {
|
||||
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 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
||||
fmt.Fprintf(c.App.Writer, "no users with tokens\n")
|
||||
}
|
||||
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)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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.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.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+`)
|
||||
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.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.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 {
|
||||
|
||||
65
cmd/user.go
65
cmd/user.go
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -25,7 +26,7 @@ func init() {
|
||||
|
||||
var flagsUser = append(
|
||||
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-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
|
||||
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:
|
||||
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||
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 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 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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -225,13 +241,13 @@ func execUserDel(c *cli.Context) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if err := manager.RemoveUser(username); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
|
||||
fmt.Fprintf(c.App.Writer, "user %s removed\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -251,7 +267,7 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if password == "" {
|
||||
@@ -263,7 +279,7 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if err := manager.ChangePassword(username, password, hashed); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -279,13 +295,26 @@ func execUserChangeRole(c *cli.Context) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if err := manager.ChangeRole(username, role); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -303,19 +332,19 @@ func execUserChangeTier(c *cli.Context) error {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if tier == tierReset {
|
||||
if err := manager.ResetTier(username); err != nil {
|
||||
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 {
|
||||
if err := manager.ChangeTier(username, tier); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -345,7 +374,15 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
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 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) {
|
||||
|
||||
@@ -15,20 +15,20 @@ func TestCLI_User_Add(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
@@ -41,10 +41,10 @@ func TestCLI_User_Add_Admin(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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) {
|
||||
@@ -60,19 +60,27 @@ func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
|
||||
|
||||
func TestCLI_User_ChangePass(t *testing.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)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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
|
||||
app, stdin, _, stderr = newTestApp()
|
||||
app, stdin, stdout, _ = newTestApp()
|
||||
stdin.WriteString("newpass\nnewpass")
|
||||
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) {
|
||||
@@ -80,15 +88,15 @@ func TestCLI_User_ChangeRole(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
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) {
|
||||
@@ -96,15 +104,15 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
// Add user
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
app, stdin, stdout, _ := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
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
|
||||
app, _, _, stderr = newTestApp()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
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)
|
||||
app, _, _, _ = newTestApp()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !noserver
|
||||
//go:build !noserver && !nowebpush
|
||||
|
||||
package cmd
|
||||
|
||||
@@ -53,9 +53,9 @@ web-push-private-key: %s
|
||||
if err != nil {
|
||||
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 {
|
||||
_, 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-private-key: %s
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -9,16 +10,18 @@ import (
|
||||
)
|
||||
|
||||
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
|
||||
app, _, _, stderr := newTestApp()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
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) {
|
||||
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.Contains(t, stderr.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, "key-file.yaml")
|
||||
require.Contains(t, stdout.String(), "Web Push keys written to key-file.yaml")
|
||||
require.FileExists(t, filepath.Join(tempDir, "key-file.yaml"))
|
||||
}
|
||||
|
||||
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
|
||||
205
docs/config.md
205
docs/config.md
@@ -88,6 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
||||
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
|
||||
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
|
||||
NTFY_AUTH_DEFAULT_ACCESS: deny-all
|
||||
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
|
||||
NTFY_BEHIND_PROXY: true
|
||||
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
|
||||
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).
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
accessing them has the right permissions.
|
||||
Once configured, you can use
|
||||
|
||||
- the `ntfy user` command and the `auth-users` config option to [add or modify users](#users-and-roles)
|
||||
- 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 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
|
||||
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)).
|
||||
@@ -221,12 +234,54 @@ ntfy user del phil # Delete 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-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)
|
||||
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:
|
||||
|
||||
```
|
||||
@@ -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
|
||||
(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
|
||||
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
|
||||
@@ -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,
|
||||
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
|
||||
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 --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
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:**
|
||||
@@ -309,32 +416,89 @@ ntfy token remove phil tk_th2sxr... # Delete token
|
||||
$ ntfy token add --expires=30d --label="backups" phil
|
||||
$ ntfy token list
|
||||
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
|
||||
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||
|
||||
### Example: Private instance
|
||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||
#### Tokens via the config
|
||||
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
|
||||
auth-file: "/var/lib/ntfy/user.db"
|
||||
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:
|
||||
|
||||
```
|
||||
$ ntfy user add --role=admin phil
|
||||
password: mypass
|
||||
confirm: mypass
|
||||
user phil added with role admin
|
||||
```
|
||||
=== "Config via env variables"
|
||||
``` yaml
|
||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
||||
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
||||
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
|
||||
NTFY_AUTH_ACCESS='backup-service:backups:rw'
|
||||
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)
|
||||
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)"
|
||||
```
|
||||
@@ -1534,6 +1698,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login |
|
||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||
|
||||
@@ -30,50 +30,56 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.12.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.12.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
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.12.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.12.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.12.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.12.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.12.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.12.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.12.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
## Debian/Ubuntu repository
|
||||
Installation via Debian repository:
|
||||
|
||||
!!! info
|
||||
As of September 2025, **the official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh/apt)**.
|
||||
The old repository [archive.heckel.io](https://archive.heckel.io/apt) is still available for now, but will likely
|
||||
go away soon. I suspect I will phase it out some time in early 2026.
|
||||
|
||||
Installation via Debian/Ubuntu repository (fingerprint `55BA 774A 6F5E E674 31E4 6B7C CFDB 962D 4F1E C4AF`):
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=amd64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -83,10 +89,10 @@ Installation via Debian repository:
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=armhf signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=armhf signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -96,10 +102,10 @@ Installation via Debian repository:
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
curl -fsSL https://archive.heckel.io/apt/pubkey.txt | sudo gpg --dearmor -o /etc/apt/keyrings/archive.heckel.io.gpg
|
||||
sudo curl -L -o /etc/apt/keyrings/ntfy.gpg https://archive.ntfy.sh/apt/keyring.gpg
|
||||
sudo apt install apt-transport-https
|
||||
sudo sh -c "echo 'deb [arch=arm64 signed-by=/etc/apt/keyrings/archive.heckel.io.gpg] https://archive.heckel.io/apt debian main' \
|
||||
> /etc/apt/sources.list.d/archive.heckel.io.list"
|
||||
echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/ntfy.gpg] https://archive.ntfy.sh/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/ntfy.list
|
||||
sudo apt update
|
||||
sudo apt install ntfy
|
||||
sudo systemctl enable ntfy
|
||||
@@ -110,7 +116,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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.15.0/ntfy_2.15.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -118,7 +124,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```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.15.0/ntfy_2.15.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -126,7 +132,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```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.15.0/ntfy_2.15.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -134,7 +140,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```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.15.0/ntfy_2.15.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -144,28 +150,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```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.15.0/ntfy_2.15.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```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.15.0/ntfy_2.15.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.12.0/ntfy_2.12.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```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.15.0/ntfy_2.15.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -195,18 +201,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](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.15.0/ntfy_2.15.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -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
|
||||
tar zxvf ntfy_2.12.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.12.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/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.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -221,10 +227,9 @@ simply run:
|
||||
brew install ntfy
|
||||
```
|
||||
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/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.15.0/ntfy_2.15.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
@@ -301,6 +306,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
init: true # needed, if healthcheck is used. Prevents zombie processes
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||
@@ -319,7 +325,6 @@ The setup for Kubernetes is very similar to that for Docker, and requires a fair
|
||||
are a few options to mix and match, including a deployment without a cache file, a stateful set with a persistent cache, and a standalone
|
||||
unmanned pod.
|
||||
|
||||
|
||||
=== "deployment"
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
|
||||
@@ -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.
|
||||
- [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
|
||||
- [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++.
|
||||
|
||||
## Projects + scripts
|
||||
|
||||
@@ -174,7 +175,11 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-me-mcp](https://github.com/gitmotion/ntfy-me-mcp) - An ntfy MCP server for sending/fetching ntfy notifications to your self-hosted ntfy server from AI Agents (supports secure token auth & more - use with npx or docker!) (Node/Typescript)
|
||||
- [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)
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
|
||||
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Overseerr and Maintainerr webhook notification to ntfy helper service (C#)
|
||||
- [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
|
||||
- [ntfy-bridge](https://github.com/AlexGaudon/ntfy-bridge) - An application to bridge Discord messages (or webhooks) to ntfy.
|
||||
- [ntailfy](https://github.com/leukosaima/ntailfy) - ntfy notifications when Tailscale devices connect/disconnect (Go)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
|
||||
212
docs/publish.md
212
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
122
docs/releases.md
122
docs/releases.md
@@ -2,7 +2,92 @@
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
### ntfy server v2.12.0
|
||||
### ntfy server v2.15.0
|
||||
Released Nov 16, 2025
|
||||
|
||||
This release adds a `require-login` flag to topics, which forces users to log in before they can
|
||||
use the web app. This is useful for self-hosters and will obviously not be enabled on ntfy.sh.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add `require-login` flag to redirect to login page if not logged in ([#1434](https://github.com/binwiederhier/ntfy/pull/1434)/[#238](https://github.com/binwiederhier/ntfy/issues/238)/[#1329](https://github.com/binwiederhier/ntfy/pull/1329), thanks to [@theatischbein](https://github.com/theatischbein) for implementing most of this)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* The official ntfy.sh Debian/Ubuntu repository has moved to [archive.ntfy.sh](https://archive.ntfy.sh) ([#1357](https://github.com/binwiederhier/ntfy/issues/1357)/[#1401](https://github.com/binwiederhier/ntfy/issues/1401), thanks to [@skibbipl](https://github.com/skibbipl) and [@lduesing](https://github.com/lduesing) for reporting)
|
||||
* 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))
|
||||
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
|
||||
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)
|
||||
|
||||
## ntfy Android app v1.17.13
|
||||
Released October 21, 2025
|
||||
|
||||
This release makes changes to comply with the Google Play policies. See [#1463](https://github.com/binwiederhier/ntfy/issues/1463)
|
||||
or [ef57cd1](https://github.com/binwiederhier/ntfy-android/commit/ef57cd1374118b3e4d7a7ab496afe337e714fff7) for details.
|
||||
|
||||
The policies do not allow directly or indirectly linking to paid plans or donation links that do not go through Google Play.
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Remove the "Donate" button from menu (all variants)
|
||||
- Change default display name from "ntfy.sh/mytopic" to "mytopic" (all variants)
|
||||
- Remove links to ntfy docs and issue tracker (Play variant only)
|
||||
- Remove how-to links to ntfy.sh in a few places (Play variant only)
|
||||
- Remove "Copy topic address" from subscription menu (Play variant only)
|
||||
|
||||
## ntfy Android app v1.17.8
|
||||
Released September 23, 2025
|
||||
|
||||
This is largely a maintenance update to ensure the SDK is up-to-date.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Markdown is now rendered if "Markdown: yes" was passed ([#310](https://github.com/binwiederhier/ntfy/issues/310), thanks to [@NiNiyas](https://github.com/NiNiyas) for reporting)
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
## 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
|
||||
Released May 29, 2025
|
||||
|
||||
This is mainly a maintenance release that updates dependencies, though since it's been over a year, there are a few
|
||||
@@ -62,7 +147,7 @@ user support in Discord/Matrix/GitHub! You rock, man!
|
||||
* Update new languages from Weblate. Thanks to all the contributors!
|
||||
* Added Tamil (தமிழ்) as a new language to the web app
|
||||
|
||||
### ntfy server v2.11.0
|
||||
## ntfy server v2.11.0
|
||||
Released May 13, 2024
|
||||
|
||||
This is a tiny release that fixes a database index issue that caused performance issues on ntfy.sh. It also fixes a bug
|
||||
@@ -77,7 +162,7 @@ and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the
|
||||
* Do not set rate visitor for non-eligible topics (no ticket)
|
||||
* Do not cache `config.js` ([#1098](https://github.com/binwiederhier/ntfy/pull/1098), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
### ntfy server v2.10.0
|
||||
## ntfy server v2.10.0
|
||||
Released Mar 27, 2024
|
||||
|
||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||
@@ -88,7 +173,7 @@ This is great for services that let you specify a webhook URL but do not let you
|
||||
|
||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
### ntfy server v2.9.0
|
||||
## ntfy server v2.9.0
|
||||
Released Mar 7, 2024
|
||||
|
||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||
@@ -1433,31 +1518,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.13.0 (UNRELEASED)
|
||||
|
||||
**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 Android app v1.16.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
* Bumped all dependencies to the latest versions (no ticket)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
_Nothing to see, move along ..._
|
||||
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 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>
|
||||
|
||||
@@ -8,13 +8,18 @@ contribute, or [build your own](../develop.md).
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img width="170" src="../../static/img/badge-fdroid.png"></a>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img width="150" src="../../static/img/badge-appstore.png"></a>
|
||||
|
||||
You can get the Android app from both [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
|
||||
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
You can get the Android app from [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy),
|
||||
[F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), or via the APKs from [GitHub Releases](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
The Google Play and F-Droid releases are largely identical, with the one exception that the F-Droid flavor does not use Firebase.
|
||||
The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
|
||||
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
|
||||
|
||||
If you're downloading the APKs from [GitHub](https://github.com/binwiederhier/ntfy-android/releases), they are signed with
|
||||
a certificate with the following SHA-256 fingerprint: `6e145d7ae685eff75468e5067e03a6c3645453343e4e181dac8b6b17ff67489d`.
|
||||
You can also query the DNS TXT records for `ntfy.sh` to find this fingerprint.
|
||||
|
||||
## Overview
|
||||
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
|
||||
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.
|
||||
|
||||
104
go.mod
104
go.mod
@@ -1,27 +1,27 @@
|
||||
module heckel.io/ntfy/v2
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.18.0 // indirect
|
||||
cloud.google.com/go/storage v1.55.0 // indirect
|
||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||
cloud.google.com/go/storage v1.57.2 // indirect
|
||||
github.com/BurntSushi/toml v1.5.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9
|
||||
github.com/gabriel-vasile/mimetype v1.4.11
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.15.0
|
||||
golang.org/x/term v0.32.0
|
||||
golang.org/x/time v0.12.0
|
||||
google.golang.org/api v0.240.0
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.256.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -30,75 +30,75 @@ 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 (
|
||||
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/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/text v0.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cloud.google.com/go v0.121.3 // indirect
|
||||
cloud.google.com/go/auth v0.16.2 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // 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/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/longrunning v0.6.7 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 // 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.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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/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/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric 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/trace v1.37.0 // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
218
go.sum
218
go.sum
@@ -1,41 +1,41 @@
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
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.3/go.mod h1:6vWF3nJWRrEUv26mMB3FEIU/o1MQNVPG1iHdisa2SJc=
|
||||
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
|
||||
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
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/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
|
||||
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
|
||||
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
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/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
|
||||
cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2znf0=
|
||||
cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY=
|
||||
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
|
||||
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.16.1/go.mod h1:aAPJq/bOyb23tBlc1K6GR+2E8sOGAeJSc8wIJVgl9SM=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
||||
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.57.2 h1:sVlym3cHGYhrp6XZKkKb+92I1V42ks2qKKpB0CF5Mb4=
|
||||
cloud.google.com/go/storage v1.57.2/go.mod h1:n5ijg4yiRXXpCu0sJTD6k+eMf7GRrJmPyr9YxLXGHOk=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
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/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 h1:UQUsRi8WTzhZntp5313l+CHIAT95ojUI2lpP/ExlZa4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 h1:lhhYARPUu3LmHysQ/igznQphfzynnqI3D75oUyw1HXk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0/go.mod h1:l9rva3ApbBpEJxSNYnwT9N4CDLrWgtq3u8736C5hyJw=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0 h1:xfK3bbi6F2RDtaZFtUdKO3osOBIhNb+xTs8lFW6yx9o=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
@@ -46,8 +46,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
|
||||
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
|
||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e h1:gt7U1Igw0xbJdyaCM5H2CnlAlPSkzrhsebQB6WQWjLA=
|
||||
github.com/cncf/xds/go v0.0.0-20251110193048-8bfbf64dc13e/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -60,18 +60,18 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
|
||||
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
|
||||
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
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/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.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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/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/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.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
@@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
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/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/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
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.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/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/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -127,26 +127,26 @@ 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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
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.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
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/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
|
||||
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
@@ -154,38 +154,38 @@ github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AO
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0 h1:B+WbN9RPsvobe6q4vP6KgM8/9plR/HNjgGBrfcOlweA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.37.0/go.mod h1:K5zQ3TT7p2ru9Qkzk0bKtCql0RGkPj9pRjpXgZJZ+rU=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.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.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
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.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -200,10 +200,10 @@ 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
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.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
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/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/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.7.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.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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-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.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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-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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
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.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -249,10 +249,10 @@ 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.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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
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/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -261,22 +261,24 @@ 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/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=
|
||||
google.golang.org/api v0.240.0 h1:PxG3AA2UIqT1ofIzWV2COM3j3JagKTKSwy7L6RHNXNU=
|
||||
google.golang.org/api v0.240.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
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/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba h1:Ze6qXW0j37YCqZdCD2LkzVSxgEWez0cO4NUyd44DiDY=
|
||||
google.golang.org/genproto v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:4FLPzLA8eGAktPOTemJGDgDYRpLYwrNu4u2JtWINhnI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
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.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -94,6 +94,7 @@ nav:
|
||||
- "Integrations + projects": integrations.md
|
||||
- "Release notes": releases.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Template functions": publish/template-functions.md
|
||||
- "Troubleshooting": troubleshooting.md
|
||||
- "Known issues": known-issues.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)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
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
|
||||
AuthStartupQueries string
|
||||
AuthDefault user.Permission
|
||||
AuthUsers []*user.User
|
||||
AuthAccess map[string][]*user.Grant
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
TemplateDir string // Directory to load named templates from
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
DisallowedTopics []string
|
||||
@@ -156,6 +162,7 @@ type Config struct {
|
||||
BillingContact string
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
EnableLogin bool
|
||||
RequireLogin bool
|
||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||
EnableMetrics bool
|
||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||
@@ -172,7 +179,7 @@ type Config struct {
|
||||
// NewConfig instantiates a default new server config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
File: "", // Only used for testing
|
||||
File: DefaultConfigFile, // Only used for testing
|
||||
BaseURL: "",
|
||||
ListenHTTP: DefaultListenHTTP,
|
||||
ListenHTTPS: "",
|
||||
@@ -195,6 +202,7 @@ func NewConfig() *Config {
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||
TemplateDir: DefaultTemplateDir,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
DisallowedTopics: DefaultDisallowedTopics,
|
||||
@@ -249,6 +257,7 @@ func NewConfig() *Config {
|
||||
EnableSignup: false,
|
||||
EnableLogin: false,
|
||||
EnableReservations: false,
|
||||
RequireLogin: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
|
||||
@@ -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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
@@ -130,6 +132,8 @@ var (
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
@@ -35,7 +37,7 @@ const (
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
@@ -72,30 +74,30 @@ const (
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesByIDQuery = `
|
||||
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
|
||||
WHERE mid = ?
|
||||
`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
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
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
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
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
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
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
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
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
@@ -105,10 +107,10 @@ const (
|
||||
WHERE topic = ? AND published = 1
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
`
|
||||
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
|
||||
FROM messages
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
`
|
||||
@@ -282,10 +284,17 @@ type messageCache struct {
|
||||
db *sql.DB
|
||||
queue *util.BatchingQueue[*message]
|
||||
nop bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// newSqliteCache creates a SQLite file-backed cache
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
// SQLite's busy_timeout is exceeded before erroring out.
|
||||
func (c *messageCache) addMessages(ms []*message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
@@ -521,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkPublished(m *message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||
return err
|
||||
}
|
||||
@@ -566,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -580,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||
}
|
||||
|
||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -614,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -759,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||
}
|
||||
|
||||
func (c *messageCache) UpdateStats(messages int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.db.Exec(updateStatsQuery, messages)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -90,6 +92,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
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) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
159
server/server.go
159
server/server.go
@@ -31,9 +31,12 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
)
|
||||
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
@@ -120,6 +123,15 @@ var (
|
||||
//go:embed docs
|
||||
docsStaticFs embed.FS
|
||||
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 (
|
||||
@@ -129,17 +141,13 @@ const (
|
||||
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
|
||||
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
|
||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||
templateMaxExecutionTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
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`)
|
||||
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
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -158,7 +166,7 @@ func New(conf *Config) (*Server, error) {
|
||||
mailer = &smtpSender{config: conf}
|
||||
}
|
||||
var stripe stripeAPI
|
||||
if conf.StripeSecretKey != "" {
|
||||
if payments.Available && conf.StripeSecretKey != "" {
|
||||
stripe = newStripeAPI()
|
||||
}
|
||||
messageCache, err := createMessageCache(conf)
|
||||
@@ -189,7 +197,18 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
var userManager *user.Manager
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -581,6 +600,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
RequireLogin: s.config.RequireLogin,
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableCalls: s.config.TwilioAccount != "",
|
||||
@@ -936,7 +956,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")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -952,7 +972,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
@@ -970,48 +990,53 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
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) {
|
||||
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 != "" {
|
||||
m.Message = messageStr
|
||||
}
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
||||
return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
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 != "" {
|
||||
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())
|
||||
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() {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
||||
return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
} 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()
|
||||
}
|
||||
@@ -1019,14 +1044,13 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
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")
|
||||
if markdown || strings.ToLower(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!
|
||||
contentEncoding := readParam(r, "content-encoding")
|
||||
if unifiedpush || contentEncoding == "aes128gcm" {
|
||||
@@ -1058,7 +1082,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
|
||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||
// 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
|
||||
return s.handleBodyDiscard(body)
|
||||
} else if unifiedpush {
|
||||
@@ -1067,8 +1091,8 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
||||
} else if template.Enabled() {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, template, body) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
@@ -1104,7 +1128,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
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))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1112,19 +1136,69 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedR
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
if template.FileMode() {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
if len(m.Message) > s.config.MessageSizeLimit {
|
||||
if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
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) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
@@ -1132,15 +1206,16 @@ func replaceTemplate(tpl string, source string) (string, error) {
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := template.New("").Parse(tpl)
|
||||
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
||||
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 {
|
||||
|
||||
@@ -82,6 +82,14 @@
|
||||
# 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
|
||||
# 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:
|
||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||
@@ -94,6 +102,9 @@
|
||||
# auth-file: <filename>
|
||||
# auth-default-access: "read-write"
|
||||
# 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
|
||||
# the visitor IP address instead of the remote address of the connection.
|
||||
@@ -126,6 +137,26 @@
|
||||
# attachment-file-size-limit: "15M"
|
||||
# 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,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
@@ -227,9 +258,11 @@
|
||||
#
|
||||
# - enable-signup allows users to sign up via the web app, or API
|
||||
# - enable-login allows users to log in via the web app, or API
|
||||
# - require-login redirects users to the login page if they are not logged in (disallows web app access without login)
|
||||
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
||||
#
|
||||
# enable-signup: false
|
||||
# require-login: false
|
||||
# enable-login: false
|
||||
# enable-reservations: false
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.Username = u.Name
|
||||
response.Role = string(u.Role)
|
||||
response.SyncTopic = u.SyncTopic
|
||||
response.Provisioned = u.Provisioned
|
||||
if u.Prefs != nil {
|
||||
if u.Prefs.Language != nil {
|
||||
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()
|
||||
}
|
||||
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
||||
Token: t.Value,
|
||||
Label: t.Label,
|
||||
LastAccess: t.LastAccess.Unix(),
|
||||
LastOrigin: lastOrigin,
|
||||
Expires: t.Expires.Unix(),
|
||||
Token: t.Value,
|
||||
Label: t.Label,
|
||||
LastAccess: t.LastAccess.Unix(),
|
||||
LastOrigin: lastOrigin,
|
||||
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 {
|
||||
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 err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
|
||||
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)
|
||||
if err := s.userManager.ChangePassword(u.Name, req.NewPassword, false); err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPConflictProvisionedUserChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
@@ -234,7 +245,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request
|
||||
"token_expires": expires,
|
||||
}).
|
||||
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 {
|
||||
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)
|
||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||
return errHTTPConflictProvisionedTokenChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
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 errors.Is(err, user.ErrProvisionedTokenChange) {
|
||||
return errHTTPConflictProvisionedTokenChange
|
||||
}
|
||||
return err
|
||||
}
|
||||
logvr(v, r).
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
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{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -251,7 +251,11 @@ func TestAccount_Subscription_AddUpdateDelete(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()
|
||||
|
||||
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"),
|
||||
})
|
||||
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) {
|
||||
|
||||
@@ -25,7 +25,7 @@ func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visit
|
||||
for i, g := range grants[u.ID] {
|
||||
userGrants[i] = &apiUserGrantResponse{
|
||||
Topic: g.TopicPattern,
|
||||
Permission: g.Allow.String(),
|
||||
Permission: g.Permission.String(),
|
||||
}
|
||||
}
|
||||
usersResponse[i] = &apiUserResponse{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nofirebase
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -14,6 +16,10 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
fcmApnsBodyMessageLimit = 100
|
||||
)
|
||||
@@ -73,7 +79,7 @@ type firebaseSenderImpl struct {
|
||||
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))
|
||||
if err != nil {
|
||||
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
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/stripe/stripe-go/v74/subscription"
|
||||
"github.com/stripe/stripe-go/v74/webhook"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
@@ -22,7 +25,7 @@ import (
|
||||
|
||||
// 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:
|
||||
//
|
||||
// - Checkout:
|
||||
@@ -464,8 +467,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
||||
billing := &user.Billing{
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: subscriptionID,
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 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
|
||||
|
||||
import (
|
||||
@@ -6,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
@@ -345,8 +348,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Nil(t, u.Tier)
|
||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, payments.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, payments.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.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!
|
||||
@@ -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, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
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{
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 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, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
require.Equal(t, payments.SubscriptionStatus(stripe.SubscriptionStatusActive), u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
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(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
|
||||
// Verify that reservations were deleted
|
||||
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{
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: payments.SubscriptionStatus(stripe.SubscriptionStatusPastDue),
|
||||
StripeSubscriptionInterval: payments.PriceRecurringInterval(stripe.PriceRecurringIntervalMonth),
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -598,7 +601,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
require.Nil(t, u.Tier)
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
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.StripeSubscriptionCancelAt.Unix())
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -22,7 +23,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -280,30 +280,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
rr = request(t, s2, "GET", "/app.html", "", nil)
|
||||
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) {
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = "" // Disable attachments
|
||||
@@ -2917,7 +2893,7 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
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) {
|
||||
@@ -2970,8 +2946,7 @@ Labels:
|
||||
Annotations:
|
||||
- summary = 15m load average too high
|
||||
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
|
||||
`, m.Message)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||
conf.AttachmentCacheDir = t.TempDir()
|
||||
conf.TemplateDir = t.TempDir()
|
||||
return conf
|
||||
}
|
||||
|
||||
@@ -3046,17 +3232,6 @@ func newTestConfigWithAuthFile(t *testing.T) *Config {
|
||||
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 {
|
||||
server, err := New(config)
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build !nowebpush
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
@@ -13,6 +15,10 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -20,6 +24,28 @@ const (
|
||||
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) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -254,3 +280,14 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
|
||||
require.Nil(t, err)
|
||||
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
|
||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||
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 {
|
||||
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 != "" {
|
||||
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/user"
|
||||
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -246,6 +245,51 @@ func (q *queryFilter) Pass(msg *message) bool {
|
||||
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 {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
@@ -316,11 +360,12 @@ type apiAccountTokenUpdateRequest struct {
|
||||
}
|
||||
|
||||
type apiAccountTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LastAccess int64 `json:"last_access,omitempty"`
|
||||
LastOrigin string `json:"last_origin,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LastAccess int64 `json:"last_access,omitempty"`
|
||||
LastOrigin string `json:"last_origin,omitempty"`
|
||||
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 {
|
||||
@@ -382,6 +427,7 @@ type apiAccountResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Provisioned bool `json:"provisioned,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
@@ -403,6 +449,7 @@ type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
|
||||
690
user/manager.go
690
user/manager.go
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/netip"
|
||||
@@ -52,10 +51,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
benGrants, err := a.Grants("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []Grant{
|
||||
{"everyonewrite", PermissionDenyAll},
|
||||
{"mytopic", PermissionReadWrite},
|
||||
{"writeme", PermissionWrite},
|
||||
{"readme", PermissionRead},
|
||||
{"everyonewrite", PermissionDenyAll, false},
|
||||
{"mytopic", PermissionReadWrite, false},
|
||||
{"writeme", PermissionWrite, false},
|
||||
{"readme", PermissionRead, false},
|
||||
}, benGrants)
|
||||
|
||||
john, err := a.Authenticate("john", "john")
|
||||
@@ -67,10 +66,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
johnGrants, err := a.Grants("john")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []Grant{
|
||||
{"mytopic_deny*", PermissionDenyAll},
|
||||
{"mytopic_ro*", PermissionRead},
|
||||
{"mytopic*", PermissionReadWrite},
|
||||
{"*", PermissionRead},
|
||||
{"mytopic_deny*", PermissionDenyAll, false},
|
||||
{"mytopic_ro*", PermissionRead, false},
|
||||
{"mytopic*", PermissionReadWrite, false},
|
||||
{"*", PermissionRead, false},
|
||||
}, johnGrants)
|
||||
|
||||
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{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionStatus: "active",
|
||||
StripeSubscriptionInterval: "month",
|
||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -194,7 +193,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
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)
|
||||
|
||||
u, err = a.Authenticate("user", "pass")
|
||||
@@ -241,7 +240,7 @@ func TestManager_CreateToken_Only_Lower(t *testing.T) {
|
||||
u, err := a.User("user")
|
||||
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.Equal(t, token.Value, strings.ToLower(token.Value))
|
||||
}
|
||||
@@ -277,10 +276,10 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
benGrants, err := a.Grants("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []Grant{
|
||||
{"everyonewrite", PermissionDenyAll},
|
||||
{"mytopic", PermissionReadWrite},
|
||||
{"writeme", PermissionWrite},
|
||||
{"readme", PermissionRead},
|
||||
{"everyonewrite", PermissionDenyAll, false},
|
||||
{"mytopic", PermissionReadWrite, false},
|
||||
{"writeme", PermissionWrite, false},
|
||||
{"readme", PermissionRead, false},
|
||||
}, benGrants)
|
||||
|
||||
everyone, err := a.User(Everyone)
|
||||
@@ -292,8 +291,8 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
everyoneGrants, err := a.Grants(Everyone)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []Grant{
|
||||
{"everyonewrite", PermissionReadWrite},
|
||||
{"announcements", PermissionRead},
|
||||
{"everyonewrite", PermissionReadWrite, false},
|
||||
{"announcements", PermissionRead, false},
|
||||
}, everyoneGrants)
|
||||
|
||||
// Ben: Before revoking
|
||||
@@ -340,7 +339,7 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
func TestManager_ChangePassword(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
@@ -354,7 +353,7 @@ func TestManager_ChangePassword(t *testing.T) {
|
||||
_, err = a.Authenticate("phil", "newpass")
|
||||
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")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
_, err = a.Authenticate("jane", "newpass")
|
||||
@@ -489,12 +488,12 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||
benGrants, err := a.Grants("ben")
|
||||
require.Nil(t, err)
|
||||
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)
|
||||
require.Nil(t, err)
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
@@ -523,7 +522,7 @@ func TestManager_Token_Valid(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// 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.NotEmpty(t, token.Value)
|
||||
require.Equal(t, "some label", token.Label)
|
||||
@@ -586,12 +585,12 @@ func TestManager_Token_Expire(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// 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.NotEmpty(t, token1.Value)
|
||||
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.NotEmpty(t, 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)
|
||||
|
||||
// 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.NotEmpty(t, token.Value)
|
||||
|
||||
@@ -668,12 +667,12 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
|
||||
// Create 2 tokens for phil
|
||||
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.NotEmpty(t, 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.NotEmpty(t, 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)
|
||||
benTokens := make([]string, 0)
|
||||
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.NotEmpty(t, 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) {
|
||||
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, a.AddUser("ben", "ben", RoleUser, false))
|
||||
|
||||
@@ -773,7 +779,14 @@ func TestManager_EnqueueStats_ResetStats(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, a.AddUser("ben", "ben", RoleUser, false))
|
||||
|
||||
@@ -781,7 +794,7 @@ func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
||||
u, err := a.User("ben")
|
||||
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)
|
||||
|
||||
// Queue token update
|
||||
@@ -806,7 +819,14 @@ func TestManager_EnqueueTokenUpdate(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, 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))
|
||||
}
|
||||
|
||||
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) {
|
||||
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.Equal(t, 2, len(benGrants))
|
||||
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, PermissionReadWrite, benGrants[1].Allow)
|
||||
require.Equal(t, PermissionReadWrite, benGrants[1].Permission)
|
||||
|
||||
require.Equal(t, "u_everyone", everyone.ID)
|
||||
require.Equal(t, Everyone, everyone.Name)
|
||||
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||
require.Equal(t, 1, len(everyoneGrants))
|
||||
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) {
|
||||
@@ -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 {
|
||||
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)
|
||||
return a
|
||||
}
|
||||
|
||||
100
user/types.go
100
user/types.go
@@ -2,27 +2,27 @@ package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/payments"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is a struct that represents a user
|
||||
type User struct {
|
||||
ID string
|
||||
Name string
|
||||
Hash string // password hash (bcrypt)
|
||||
Token string // Only set if token was used to log in
|
||||
Role Role
|
||||
Prefs *Prefs
|
||||
Tier *Tier
|
||||
Stats *Stats
|
||||
Billing *Billing
|
||||
SyncTopic string
|
||||
Deleted bool
|
||||
ID string
|
||||
Name string
|
||||
Hash string // Password hash (bcrypt)
|
||||
Token string // Only set if token was used to log in
|
||||
Role Role
|
||||
Prefs *Prefs
|
||||
Tier *Tier
|
||||
Stats *Stats
|
||||
Billing *Billing
|
||||
SyncTopic string
|
||||
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,
|
||||
@@ -58,11 +58,12 @@ type Auther interface {
|
||||
|
||||
// Token represents a user token, including expiry date
|
||||
type Token struct {
|
||||
Value string
|
||||
Label string
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
Expires time.Time
|
||||
Value string
|
||||
Label string
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
Expires time.Time
|
||||
Provisioned bool
|
||||
}
|
||||
|
||||
// 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 {
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
||||
StripeSubscriptionStatus payments.SubscriptionStatus
|
||||
StripeSubscriptionInterval payments.PriceRecurringInterval
|
||||
StripeSubscriptionPaidUntil 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
|
||||
type Grant struct {
|
||||
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
|
||||
@@ -240,48 +242,20 @@ const (
|
||||
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
|
||||
var (
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTierNotFound = errors.New("tier not found")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrPhoneNumberNotFound = errors.New("phone number not found")
|
||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||
ErrPhoneNumberExists = errors.New("phone number already exists")
|
||||
ErrUnauthenticated = errors.New("unauthenticated")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
|
||||
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
|
||||
ErrTierNotFound = errors.New("tier not found")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
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
|
||||
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
|
||||
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.
|
||||
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
func RandomString(length int) string {
|
||||
return RandomStringPrefix("", length)
|
||||
|
||||
1141
web/package-lock.json
generated
1141
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user