mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Merge pull request #1826 from binwiederhier/template-execute-context
Template execution rework
This commit is contained in:
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.x'
|
go-version-file: '.go-version'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.x'
|
go-version-file: '.go-version'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.x'
|
go-version-file: '.go-version'
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1.26.5
|
||||||
@@ -92,7 +92,7 @@ clean: FORCE
|
|||||||
|
|
||||||
build: web docs cli
|
build: web docs cli
|
||||||
|
|
||||||
update: web-deps-update cli-deps-update docs-deps-update
|
update: web-deps-update cli-deps-update docs-deps-update go-check
|
||||||
docker pull alpine
|
docker pull alpine
|
||||||
|
|
||||||
docker-dev:
|
docker-dev:
|
||||||
@@ -273,9 +273,9 @@ cli-build-results:
|
|||||||
|
|
||||||
# Test/check targets
|
# Test/check targets
|
||||||
|
|
||||||
check: test web-fmt-check fmt-check vet web-lint lint staticcheck
|
check: test web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||||
|
|
||||||
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck
|
checkv: testv web-fmt-check fmt-check vet web-lint lint staticcheck template-check go-check
|
||||||
|
|
||||||
test: cli-test web-test
|
test: cli-test web-test
|
||||||
|
|
||||||
@@ -317,17 +317,73 @@ vet:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
which golint || go install golang.org/x/lint/golint@latest
|
which golint || go install golang.org/x/lint/golint@latest
|
||||||
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
go list ./... | grep -v /vendor/ | grep -vE 'ntfy/v2/template/gotext' | xargs -L1 golint -set_exit_status
|
||||||
|
|
||||||
staticcheck: FORCE
|
staticcheck: FORCE
|
||||||
rm -rf build/staticcheck
|
rm -rf build/staticcheck
|
||||||
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
which staticcheck || go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||||
mkdir -p build/staticcheck
|
mkdir -p build/staticcheck
|
||||||
ln -s "go" build/staticcheck/go
|
ln -s "go" build/staticcheck/go
|
||||||
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
|
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck $$(go list ./... | grep -vE 'ntfy/v2/template/gotext')
|
||||||
rm -rf build/staticcheck
|
rm -rf build/staticcheck
|
||||||
|
|
||||||
|
|
||||||
|
# Vendored template targets (see template/README.md)
|
||||||
|
|
||||||
|
TEMPLATE_GO_VERSION := go$(shell cat .go-version 2>/dev/null)
|
||||||
|
|
||||||
|
update-template:
|
||||||
|
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||||
|
echo "ERROR: local Go $$(go env GOVERSION) != $(TEMPLATE_GO_VERSION) pinned in .go-version."; \
|
||||||
|
echo "Bump .go-version and install that toolchain first: go install golang.org/dl/$(TEMPLATE_GO_VERSION)@latest && $(TEMPLATE_GO_VERSION) download"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
src="$$(go env GOROOT)/src"; \
|
||||||
|
rm -f template/gotext/*.go template/gotext/fmtsort/*.go; \
|
||||||
|
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" template/gotext/; done; \
|
||||||
|
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" template/gotext/fmtsort/; done; \
|
||||||
|
sed -i 's/^package template$$/package gotext/' template/gotext/*.go; \
|
||||||
|
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' template/gotext/*.go; \
|
||||||
|
( cd template/gotext && for p in patches/*.patch; do echo "Applying $$p"; git apply "$$p" || exit 1; done )
|
||||||
|
go env GOVERSION > template/gotext/GENERATED_FROM
|
||||||
|
@echo "Regenerated template/gotext/ from $(TEMPLATE_GO_VERSION) (files enumerated via 'go list'); review with 'git diff'."
|
||||||
|
|
||||||
|
template-check: FORCE
|
||||||
|
@if [ "$$(cat template/gotext/GENERATED_FROM)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||||
|
echo "ERROR: template/gotext was generated from $$(cat template/gotext/GENERATED_FROM), but .go-version pins $(TEMPLATE_GO_VERSION). Run 'make update-template' on the pinned Go."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ "$$(go env GOVERSION)" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||||
|
echo "SKIP: local Go $$(go env GOVERSION) != pinned $(TEMPLATE_GO_VERSION); skipping vendored template content check (version marker already verified)."; \
|
||||||
|
exit 0; \
|
||||||
|
fi
|
||||||
|
@tmp=$$(mktemp -d); src="$$(go env GOROOT)/src"; \
|
||||||
|
mkdir -p "$$tmp/gotext/fmtsort"; \
|
||||||
|
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' text/template); do cp "$$src/text/template/$$f" "$$tmp/gotext/"; done; \
|
||||||
|
for f in $$(go list -f '{{range .GoFiles}}{{.}} {{end}}' internal/fmtsort); do cp "$$src/internal/fmtsort/$$f" "$$tmp/gotext/fmtsort/"; done; \
|
||||||
|
sed -i 's/^package template$$/package gotext/' "$$tmp/gotext/"*.go; \
|
||||||
|
sed -i 's#"internal/fmtsort"#"heckel.io/ntfy/v2/template/gotext/fmtsort"#' "$$tmp/gotext/"*.go; \
|
||||||
|
cp template/gotext/patches/*.patch "$$tmp/"; \
|
||||||
|
( cd "$$tmp/gotext" && for p in "$$tmp"/*.patch; do git apply "$$p" || exit 1; done ); \
|
||||||
|
if diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext >/dev/null 2>&1; then \
|
||||||
|
rm -rf "$$tmp"; \
|
||||||
|
else \
|
||||||
|
echo "ERROR: template/gotext/ drifted from GOROOT+patches (or its file set changed). Run 'make update-template' on Go $(TEMPLATE_GO_VERSION):"; \
|
||||||
|
diff -rq -x 'README.md' -x 'GENERATED_FROM' -x 'patches' "$$tmp/gotext" template/gotext; \
|
||||||
|
rm -rf "$$tmp"; exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# go-check is advisory only (never fails): it warns when the pinned Go (.go-version) is behind the
|
||||||
|
# latest upstream release, so template/gotext doesn't silently fall behind on text/template fixes.
|
||||||
|
go-check: FORCE
|
||||||
|
@latest=$$(curl -s --max-time 10 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); \
|
||||||
|
if [ -n "$$latest" ] && [ "$$latest" != "$(TEMPLATE_GO_VERSION)" ]; then \
|
||||||
|
echo ""; \
|
||||||
|
echo "note: latest Go is $$latest, but template/gotext is pinned to $(TEMPLATE_GO_VERSION) (.go-version)."; \
|
||||||
|
echo " to bump: install $$latest, set .go-version to $${latest#go}, then run 'make update-template'."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
# Releasing targets
|
# Releasing targets
|
||||||
|
|
||||||
release: clean cli-deps release-checks docs web check
|
release: clean cli-deps release-checks docs web check
|
||||||
@@ -338,6 +394,10 @@ release-snapshot: clean cli-deps docs web check
|
|||||||
|
|
||||||
release-checks:
|
release-checks:
|
||||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||||
|
if [ "$$(go env GOVERSION)" != "go$$(cat .go-version)" ]; then\
|
||||||
|
echo "ERROR: releases must use the pinned Go toolchain (go$$(cat .go-version) from .go-version), but this is $$(go env GOVERSION). This also ensures 'make check' enforces (not skips) the template/gotext drift check.";\
|
||||||
|
exit 1;\
|
||||||
|
fi
|
||||||
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
if ! grep -q $(LATEST_TAG) docs/install.md; then\
|
||||||
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
echo "ERROR: Must update docs/install.md with latest tag first.";\
|
||||||
exit 1;\
|
exit 1;\
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ Third-party libraries and resources:
|
|||||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||||
|
* Go's [text/template](https://pkg.go.dev/text/template) (BSD-3-Clause) is vendored under [template/gotext/](template/gotext/) with a small patch adding an execution deadline (see [template/gotext/README.md](template/gotext/README.md))
|
||||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||||
|
|||||||
@@ -3224,6 +3224,11 @@ You can use the following features in your templates:
|
|||||||
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
|
A good way to experiment with Go templates is the **[Go Template Playground](https://repeatit.io)**. It is _highly recommended_ to test
|
||||||
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
|
your templates there first ([example for Grafana alert](https://repeatit.io/#/share/eyJ0ZW1wbGF0ZSI6InRpdGxlPUdyYWZhbmErYWxlcnQ6K3t7LnRpdGxlfX0mbWVzc2FnZT17ey5tZXNzYWdlfX0iLCJpbnB1dCI6IntcbiAgXCJyZWNlaXZlclwiOiBcIm50ZnlcXFxcLmV4YW1wbGVcXFxcLmNvbS9hbGVydHNcIixcbiAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICBcImFsZXJ0c1wiOiBbXG4gICAge1xuICAgICAgXCJzdGF0dXNcIjogXCJyZXNvbHZlZFwiLFxuICAgICAgXCJsYWJlbHNcIjoge1xuICAgICAgICBcImFsZXJ0bmFtZVwiOiBcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFwiLFxuICAgICAgICBcImdyYWZhbmFfZm9sZGVyXCI6IFwiTm9kZSBhbGVydHNcIixcbiAgICAgICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgICAgICBcImpvYlwiOiBcIm5vZGUtZXhwb3J0ZXJcIlxuICAgICAgfSxcbiAgICAgIFwiYW5ub3RhdGlvbnNcIjoge1xuICAgICAgICBcInN1bW1hcnlcIjogXCIxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXCJcbiAgICAgIH0sXG4gICAgICBcInN0YXJ0c0F0XCI6IFwiMjAyNC0wMy0xNVQwMjoyODowMFpcIixcbiAgICAgIFwiZW5kc0F0XCI6IFwiMjAyNC0wMy0xNVQwMjo0MjowMFpcIixcbiAgICAgIFwiZ2VuZXJhdG9yVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvZ3JhZmFuYS9OVzlvRHctNHovdmlld1wiLFxuICAgICAgXCJmaW5nZXJwcmludFwiOiBcImJlY2JmYjk0YmQ4MWVmNDhcIixcbiAgICAgIFwic2lsZW5jZVVSTFwiOiBcImxvY2FsaG9zdDozMDAwL2FsZXJ0aW5nL3NpbGVuY2UvbmV3P2FsZXJ0bWFuYWdlcj1ncmFmYW5hJm1hdGNoZXI9YWxlcnRuYW1lJTNETG9hZCthdmcrMTVtK3RvbytoaWdoJm1hdGNoZXI9Z3JhZmFuYV9mb2xkZXIlM0ROb2RlK2FsZXJ0cyZtYXRjaGVyPWluc3RhbmNlJTNEMTAuMTA4LjAuMiUzQTkxMDAmbWF0Y2hlcj1qb2IlM0Rub2RlLWV4cG9ydGVyXCIsXG4gICAgICBcImRhc2hib2FyZFVSTFwiOiBcIlwiLFxuICAgICAgXCJwYW5lbFVSTFwiOiBcIlwiLFxuICAgICAgXCJ2YWx1ZXNcIjoge1xuICAgICAgICBcIkJcIjogMTguOTgyMTEzMTQ0NzU4NzYsXG4gICAgICAgIFwiQ1wiOiAwXG4gICAgICB9LFxuICAgICAgXCJ2YWx1ZVN0cmluZ1wiOiBcIlsgdmFyPSdCJyBsYWJlbHM9e19fbmFtZV9fPW5vZGVfbG9hZDE1LCBpbnN0YW5jZT0xMC4xMDguMC4yOjkxMDAsIGpvYj1ub2RlLWV4cG9ydGVyfSB2YWx1ZT0xOC45ODIxMTMxNDQ3NTg3NiBdLCBbIHZhcj0nQycgbGFiZWxzPXtfX25hbWVfXz1ub2RlX2xvYWQxNSwgaW5zdGFuY2U9MTAuMTA4LjAuMjo5MTAwLCBqb2I9bm9kZS1leHBvcnRlcn0gdmFsdWU9MCBdXCJcbiAgICB9XG4gIF0sXG4gIFwiZ3JvdXBMYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCJcbiAgfSxcbiAgXCJjb21tb25MYWJlbHNcIjoge1xuICAgIFwiYWxlcnRuYW1lXCI6IFwiTG9hZCBhdmcgMTVtIHRvbyBoaWdoXCIsXG4gICAgXCJncmFmYW5hX2ZvbGRlclwiOiBcIk5vZGUgYWxlcnRzXCIsXG4gICAgXCJpbnN0YW5jZVwiOiBcIjEwLjEwOC4wLjI6OTEwMFwiLFxuICAgIFwiam9iXCI6IFwibm9kZS1leHBvcnRlclwiXG4gIH0sXG4gIFwiY29tbW9uQW5ub3RhdGlvbnNcIjoge1xuICAgIFwic3VtbWFyeVwiOiBcIjE1bSBsb2FkIGF2ZXJhZ2UgdG9vIGhpZ2hcIlxuICB9LFxuICBcImV4dGVybmFsVVJMXCI6IFwibG9jYWxob3N0OjMwMDAvXCIsXG4gIFwidmVyc2lvblwiOiBcIjFcIixcbiAgXCJncm91cEtleVwiOiBcInt9OnthbGVydG5hbWU9XFxcIkxvYWQgYXZnIDE1bSB0b28gaGlnaFxcXCIsIGdyYWZhbmFfZm9sZGVyPVxcXCJOb2RlIGFsZXJ0c1xcXCJ9XCIsXG4gIFwidHJ1bmNhdGVkQWxlcnRzXCI6IDAsXG4gIFwib3JnSWRcIjogMSxcbiAgXCJ0aXRsZVwiOiBcIltSRVNPTFZFRF0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoIE5vZGUgYWxlcnRzICgxMC4xMDguMC4yOjkxMDAgbm9kZS1leHBvcnRlcilcIixcbiAgXCJzdGF0ZVwiOiBcIm9rXCIsXG4gIFwibWVzc2FnZVwiOiBcIioqUmVzb2x2ZWQqKlxcblxcblZhbHVlOiBCPTE4Ljk4MjExMzE0NDc1ODc2LCBDPTBcXG5MYWJlbHM6XFxuIC0gYWxlcnRuYW1lID0gTG9hZCBhdmcgMTVtIHRvbyBoaWdoXFxuIC0gZ3JhZmFuYV9mb2xkZXIgPSBOb2RlIGFsZXJ0c1xcbiAtIGluc3RhbmNlID0gMTAuMTA4LjAuMjo5MTAwXFxuIC0gam9iID0gbm9kZS1leHBvcnRlclxcbkFubm90YXRpb25zOlxcbiAtIHN1bW1hcnkgPSAxNW0gbG9hZCBhdmVyYWdlIHRvbyBoaWdoXFxuU291cmNlOiBsb2NhbGhvc3Q6MzAwMC9hbGVydGluZy9ncmFmYW5hL05XOW9Edy00ei92aWV3XFxuU2lsZW5jZTogbG9jYWxob3N0OjMwMDAvYWxlcnRpbmcvc2lsZW5jZS9uZXc/YWxlcnRtYW5hZ2VyPWdyYWZhbmEmbWF0Y2hlcj1hbGVydG5hbWUlM0RMb2FkK2F2ZysxNW0rdG9vK2hpZ2gmbWF0Y2hlcj1ncmFmYW5hX2ZvbGRlciUzRE5vZGUrYWxlcnRzJm1hdGNoZXI9aW5zdGFuY2UlM0QxMC4xMDguMC4yJTNBOTEwMCZtYXRjaGVyPWpvYiUzRG5vZGUtZXhwb3J0ZXJcXG5cIlxufVxuIiwiY29uZmlnIjp7InRlbXBsYXRlIjoidGV4dCIsImZ1bGxTY3JlZW5IVE1MIjpmYWxzZSwiZnVuY3Rpb25zIjpbInNwcmlnIl0sIm9wdGlvbnMiOlsibGl2ZSJdLCJpbnB1dFR5cGUiOiJ5YW1sIn19)).
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
|
||||||
|
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
|
||||||
|
a template that loops too long is stopped and rejected with an HTTP 400 error.
|
||||||
|
|
||||||
### Template functions
|
### Template functions
|
||||||
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
|
||||||
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
|
thank you to the Sprig developers 🙏). This is useful for advanced message templating and for transforming the data provided through the JSON payload.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ since I do have to reset accounts on a regular basis.
|
|||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**Bug fixes + maintenance:**
|
||||||
|
|
||||||
|
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp))
|
||||||
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
* `X-Email: yes` (also `true`/`1`) now sends to your primary verified email regardless of the `smtp-sender-verify` setting (previously it was rejected unless verification was enabled); it requires being logged in with a verified address
|
||||||
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
* Grant users full access to their own sync topic (`st_...`) so cross-device subscription sync works under `auth-default-access: deny-all` ([#733](https://github.com/binwiederhier/ntfy/issues/733), [#1795](https://github.com/binwiederhier/ntfy/pull/1795), thanks to [@lmorchard](https://github.com/lmorchard) for the contribution)
|
||||||
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
* Support HTTP (non-TLS) S3-compatible endpoints by preserving the endpoint scheme, e.g. for a local MinIO instance ([#1794](https://github.com/binwiederhier/ntfy/pull/1794), [#1734](https://github.com/binwiederhier/ntfy/issues/1734), thanks to [@sskender](https://github.com/sskender) for the contribution, and [@Kernald](https://github.com/Kernald) for reporting)
|
||||||
|
|||||||
+2
-1
@@ -136,8 +136,9 @@ var (
|
|||||||
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, define, or block", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
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}
|
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
|||||||
+1
-4
@@ -150,10 +150,7 @@ var (
|
|||||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||||
templatesDir = "templates"
|
templatesDir = "templates"
|
||||||
|
|
||||||
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||||
// are not useful, and seem potentially troublesome.
|
|
||||||
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
|
||||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template/parse"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
"heckel.io/ntfy/v2/model"
|
"heckel.io/ntfy/v2/model"
|
||||||
|
"heckel.io/ntfy/v2/template/gotext"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"heckel.io/ntfy/v2/util/sprig"
|
"heckel.io/ntfy/v2/util/sprig"
|
||||||
)
|
)
|
||||||
@@ -103,21 +106,81 @@ func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, p
|
|||||||
|
|
||||||
// renderTemplate renders a template with the given JSON source data.
|
// renderTemplate renders a template with the given JSON source data.
|
||||||
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
||||||
if templateDisallowedRegex.MatchString(tpl) {
|
|
||||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
|
||||||
}
|
|
||||||
var data any
|
var data any
|
||||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
}
|
}
|
||||||
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||||
}
|
}
|
||||||
|
if templateUsesDisallowedFeatures(t) {
|
||||||
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
|
}
|
||||||
|
t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
|
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
||||||
if err := t.Execute(limitWriter, data); err != nil {
|
if err := t.Execute(limitWriter, data); err != nil {
|
||||||
|
if errors.Is(err, gotext.ErrExecutionInterrupted) {
|
||||||
|
return "", errHTTPBadRequestTemplateExecutionTimeout
|
||||||
|
}
|
||||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// templateUsesDisallowedFeatures reports whether the parsed template defines or invokes a
|
||||||
|
// sub-template ({{define}}/{{block}}/{{template}}) or uses the {{call}} builtin. None are useful for
|
||||||
|
// ntfy's JSON-data templates. Checking the parse tree (rather than the raw string) catches every
|
||||||
|
// syntactic form -- e.g. {{if call .x}} or {{$y := call .x}} -- that a regex would miss.
|
||||||
|
func templateUsesDisallowedFeatures(t *gotext.Template) bool {
|
||||||
|
if len(t.Templates()) > 1 { // {{define}}/{{block}} create additional associated templates
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return treeContainsDisallowedNode(t.Root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeContainsDisallowedNode reports whether the parse tree contains a {{template}}/{{block}}
|
||||||
|
// invocation or a {{call}} builtin, descending into pipes and command arguments (where {{call}} can
|
||||||
|
// appear anywhere a function is allowed).
|
||||||
|
func treeContainsDisallowedNode(node parse.Node) bool {
|
||||||
|
switch n := node.(type) {
|
||||||
|
case *parse.ListNode:
|
||||||
|
if n == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, child := range n.Nodes {
|
||||||
|
if treeContainsDisallowedNode(child) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *parse.ActionNode:
|
||||||
|
return treeContainsDisallowedNode(n.Pipe)
|
||||||
|
case *parse.RangeNode:
|
||||||
|
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||||
|
case *parse.IfNode:
|
||||||
|
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||||
|
case *parse.WithNode:
|
||||||
|
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||||
|
case *parse.TemplateNode: // {{template}} or {{block}} invocation
|
||||||
|
return true
|
||||||
|
case *parse.PipeNode:
|
||||||
|
if n == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, cmd := range n.Cmds {
|
||||||
|
if treeContainsDisallowedNode(cmd) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *parse.CommandNode:
|
||||||
|
for _, arg := range n.Args {
|
||||||
|
if treeContainsDisallowedNode(arg) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *parse.IdentifierNode: // a function name; {{call}} is the disallowed builtin
|
||||||
|
return n.Ident == "call"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
+155
-3
@@ -3635,6 +3635,151 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
// Nested range over a 1000-element JSON field with a no-output body: no Write ever happens,
|
||||||
|
// so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's
|
||||||
|
// wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp).
|
||||||
|
elems := make([]string, 1000)
|
||||||
|
for i := range elems {
|
||||||
|
elems[i] = "0"
|
||||||
|
}
|
||||||
|
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||||
|
msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done`
|
||||||
|
start := time.Now()
|
||||||
|
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||||
|
"X-Message": msg,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut is the regression for the exact hole the
|
||||||
|
// old write-triggered TimeoutWriter missed: a nested {{range}} over a JSON array field with a
|
||||||
|
// no-output body calls no function, so only the executor's wall-clock deadline can stop it
|
||||||
|
// (GHSA-rhwf-xgc9-m9fp).
|
||||||
|
func TestServer_MessageTemplate_DataDrivenNestedRange_TimesOut(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
elems := make([]string, 1000)
|
||||||
|
for i := range elems {
|
||||||
|
elems[i] = "0"
|
||||||
|
}
|
||||||
|
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
|
||||||
|
msg := `{{range .a}}{{range $.a}}{{range $.a}}{{$x := .}}{{end}}{{end}}{{end}}done`
|
||||||
|
start := time.Now()
|
||||||
|
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
|
||||||
|
"X-Message": msg,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Less(t, elapsed, 500*time.Millisecond, "data-driven nested range should be cut off by the deadline (took %s)", elapsed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut ensures the deadline also bounds loops
|
||||||
|
// whose body calls an expensive function (hashing a large string), where a single call between
|
||||||
|
// deadline checks could otherwise overshoot (GHSA-rhwf-xgc9-m9fp).
|
||||||
|
func TestServer_MessageTemplate_ExpensiveFunctionLoop_TimesOut(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
msg := `{{$big := repeat 990 "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789"}}{{range until 1000}}{{range until 1000}}{{$h := sha512sum $big}}{{end}}{{end}}`
|
||||||
|
start := time.Now()
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": msg,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Less(t, elapsed, 1500*time.Millisecond, "expensive-function loop should be cut off by the deadline (took %s)", elapsed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServer_MessageTemplate_NestedLoopPoC_TimesOut is the exact proof-of-concept from the advisory:
|
||||||
|
// a range over a runtime-computed slice, nested, must be bounded by the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||||
|
func TestServer_MessageTemplate_NestedLoopPoC_TimesOut(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
start := time.Now()
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
require.Less(t, elapsed, 500*time.Millisecond, "advisory PoC should be cut off by the deadline (took %s)", elapsed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) {
|
||||||
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||||
|
// A real runtime error (len of an int) must map to execute-failed, not the timeout code.
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
|
||||||
|
"X-Message": `{{ len 5 }}`,
|
||||||
|
"X-Template": "1",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowBody delivers its data after a delay, simulating a slow client upload of the request body.
|
||||||
|
type slowBody struct {
|
||||||
|
data []byte
|
||||||
|
delay time.Duration
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *slowBody) Read(p []byte) (int, error) {
|
||||||
|
if b.done {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
time.Sleep(b.delay)
|
||||||
|
n := copy(p, b.data)
|
||||||
|
b.done = true
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *slowBody) Close() error { return nil }
|
||||||
|
|
||||||
|
// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body
|
||||||
|
// upload does not consume the template execution deadline: the body is fully read (util.Peek)
|
||||||
|
// before the deadline starts, so a trivial template still renders even when the upload alone took
|
||||||
|
// longer than the deadline (GHSA-rhwf-xgc9-m9fp).
|
||||||
|
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t, ""))
|
||||||
|
start := time.Now()
|
||||||
|
// The loop makes the template execute enough nodes (>256) to actually hit the deadline check,
|
||||||
|
// so this test distinguishes correct behavior from a deadline that includes upload time -- yet
|
||||||
|
// it runs in ~1ms, far under the deadline, so on correct code it renders fine.
|
||||||
|
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||||
|
"Template": "yes",
|
||||||
|
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
|
||||||
|
}, func(r *http.Request) {
|
||||||
|
r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime}
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful")
|
||||||
|
require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "hello bar", m.Message)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -3729,11 +3874,18 @@ func TestServer_MessageTemplate_DisallowedCalls(t *testing.T) {
|
|||||||
`{{- template ""}}`,
|
`{{- template ""}}`,
|
||||||
`{{-
|
`{{-
|
||||||
template ""}}`,
|
template ""}}`,
|
||||||
`{{ call abc}}`,
|
`{{ call "aa"}}`,
|
||||||
`{{ define "aa"}}`,
|
`{{define "aa"}}hi{{end}}`,
|
||||||
`We cannot {{define "aa"}}`,
|
`We cannot {{define "aa"}}hi{{end}}`,
|
||||||
`We cannot {{ call "aa"}}`,
|
`We cannot {{ call "aa"}}`,
|
||||||
`We cannot {{- template "aa"}}`,
|
`We cannot {{- template "aa"}}`,
|
||||||
|
`{{block "aa" .}}hi{{end}}`,
|
||||||
|
`We cannot {{- block "aa" .}}hi{{end}}`,
|
||||||
|
// call is a function, not a keyword, so it can hide in non-leading positions that a
|
||||||
|
// raw-string regex misses -- the parse-tree walk catches all of them.
|
||||||
|
`{{if call .x}}x{{end}}`,
|
||||||
|
`{{$y := call .x}}`,
|
||||||
|
`{{index (call .x) 0}}`,
|
||||||
}
|
}
|
||||||
for _, disallowedTemplate := range disallowedTemplates {
|
for _, disallowedTemplate := range disallowedTemplates {
|
||||||
messageTemplate := disallowedTemplate
|
messageTemplate := disallowedTemplate
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
go1.26.5
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# `template/gotext/` -- vendored `text/template` with an execution deadline
|
||||||
|
|
||||||
|
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
|
||||||
|
small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop
|
||||||
|
**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||||
|
from burning CPU.
|
||||||
|
|
||||||
|
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
|
||||||
|
- **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM`
|
||||||
|
- **Local modifications:** `patches/` (see [The patch](#the-patch))
|
||||||
|
- **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain))
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
|
||||||
|
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
|
||||||
|
([golang/go#31107](https://github.com/golang/go/issues/31107) was declined). So a crafted template
|
||||||
|
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
|
||||||
|
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
|
||||||
|
(GHSA-rhwf-xgc9-m9fp).
|
||||||
|
|
||||||
|
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
|
||||||
|
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
|
||||||
|
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
|
||||||
|
a **single check inside `walk`**: every ~256 nodes it checks a wall-clock deadline and aborts (via
|
||||||
|
the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap
|
||||||
|
loops and expensive functions alike -- by construction.
|
||||||
|
|
||||||
|
The one user-facing execution site (`server/server_template.go` `renderTemplate`) sets the deadline
|
||||||
|
with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator
|
||||||
|
config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied.
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
| File | Origin |
|
||||||
|
|------|--------|
|
||||||
|
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
|
||||||
|
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
|
||||||
|
| `patches/0001-exec-deadline.patch` | our only real change (see below) |
|
||||||
|
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
|
||||||
|
|
||||||
|
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
|
||||||
|
file (the single source of truth, also consumed by CI and the `make` targets below). `GENERATED_FROM`
|
||||||
|
must equal it -- `make check` fails otherwise (see below).
|
||||||
|
|
||||||
|
We do **not** vendor `text/template/parse` -- it's a normal importable stdlib package and stays a
|
||||||
|
plain import.
|
||||||
|
|
||||||
|
## The patch
|
||||||
|
|
||||||
|
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
|
||||||
|
`0001-exec-deadline.patch` -- small, purely additive, and touching only `exec.go`/`template.go`:
|
||||||
|
|
||||||
|
- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a
|
||||||
|
`SetExecutionDeadline(time.Time)` method on `Template`
|
||||||
|
- adds the amortized deadline check at the top of `state.walk`
|
||||||
|
- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`)
|
||||||
|
|
||||||
|
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
|
||||||
|
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
|
||||||
|
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
|
||||||
|
whatever files `go list` returns, so they survive upstream files being added or removed.
|
||||||
|
|
||||||
|
Keeping the patch tiny (deadline logic only, on two stable files) is deliberate: it makes re-basing
|
||||||
|
onto a new Go release cheap.
|
||||||
|
|
||||||
|
## Updating (when bumping the Go toolchain)
|
||||||
|
|
||||||
|
The copy is **pinned to the Go version in the root `.go-version`**, so it's not frozen -- re-syncing
|
||||||
|
on a Go bump pulls in all upstream fixes for free. `.go-version` is authoritative and hand-edited; to
|
||||||
|
bump the toolchain: edit `.go-version`, install that toolchain
|
||||||
|
(`go install golang.org/dl/<version>@latest && <version> download`), then re-sync:
|
||||||
|
|
||||||
|
```
|
||||||
|
make update-template # copies the files from your GOROOT and re-applies patches/*.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
`make update-template` **errors** unless your local Go matches `.go-version` -- it validates against
|
||||||
|
the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the
|
||||||
|
patch as part of the bump.
|
||||||
|
|
||||||
|
`make template-check` (wired into `make check`) has two layers:
|
||||||
|
|
||||||
|
1. **Marker check (ungated, runs on any toolchain):** fails if `GENERATED_FROM` != `.go-version`, i.e.
|
||||||
|
someone bumped the pin but forgot `make update-template` (or vice versa). This catches the common
|
||||||
|
mistake locally, on any developer's Go.
|
||||||
|
2. **Content check (gated to the pinned Go):** re-derives the copy from `GOROOT + patches` and diffs it
|
||||||
|
against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned
|
||||||
|
toolchain so it never fails spuriously.
|
||||||
|
|
||||||
|
CI installs exactly `.go-version` (`go-version-file`), so both layers run there. `make release`
|
||||||
|
additionally refuses to run off the pinned Go, so the content check is never skipped for a release.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each
|
||||||
|
file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing.
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
// Copyright 2011 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
/*
|
||||||
|
Package template implements data-driven templates for generating textual output.
|
||||||
|
|
||||||
|
To generate HTML output, see [html/template], which has the same interface
|
||||||
|
as this package but automatically secures HTML output against certain attacks.
|
||||||
|
|
||||||
|
Templates are executed by applying them to a data structure. Annotations in the
|
||||||
|
template refer to elements of the data structure (typically a field of a struct
|
||||||
|
or a key in a map) to control execution and derive values to be displayed.
|
||||||
|
Execution of the template walks the structure and sets the cursor, represented
|
||||||
|
by a period '.' and called "dot", to the value at the current location in the
|
||||||
|
structure as execution proceeds.
|
||||||
|
|
||||||
|
The security model used by this package assumes that template authors are
|
||||||
|
trusted. The package does not auto-escape output, so injecting code into
|
||||||
|
a template can lead to arbitrary code execution if the template is executed
|
||||||
|
by an untrusted source.
|
||||||
|
|
||||||
|
The input text for a template is UTF-8-encoded text in any format.
|
||||||
|
"Actions"--data evaluations or control structures--are delimited by
|
||||||
|
"{{" and "}}"; all text outside actions is copied to the output unchanged.
|
||||||
|
|
||||||
|
Once parsed, a template may be executed safely in parallel, although if parallel
|
||||||
|
executions share a Writer the output may be interleaved.
|
||||||
|
|
||||||
|
Here is a trivial example that prints "17 items are made of wool".
|
||||||
|
|
||||||
|
type Inventory struct {
|
||||||
|
Material string
|
||||||
|
Count uint
|
||||||
|
}
|
||||||
|
sweaters := Inventory{"wool", 17}
|
||||||
|
tmpl, err := template.New("test").Parse("{{.Count}} items are made of {{.Material}}")
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
err = tmpl.Execute(os.Stdout, sweaters)
|
||||||
|
if err != nil { panic(err) }
|
||||||
|
|
||||||
|
More intricate examples appear below.
|
||||||
|
|
||||||
|
Text and spaces
|
||||||
|
|
||||||
|
By default, all text between actions is copied verbatim when the template is
|
||||||
|
executed. For example, the string " items are made of " in the example above
|
||||||
|
appears on standard output when the program is run.
|
||||||
|
|
||||||
|
However, to aid in formatting template source code, if an action's left
|
||||||
|
delimiter (by default "{{") is followed immediately by a minus sign and white
|
||||||
|
space, all trailing white space is trimmed from the immediately preceding text.
|
||||||
|
Similarly, if the right delimiter ("}}") is preceded by white space and a minus
|
||||||
|
sign, all leading white space is trimmed from the immediately following text.
|
||||||
|
In these trim markers, the white space must be present:
|
||||||
|
"{{- 3}}" is like "{{3}}" but trims the immediately preceding text, while
|
||||||
|
"{{-3}}" parses as an action containing the number -3.
|
||||||
|
|
||||||
|
For instance, when executing the template whose source is
|
||||||
|
|
||||||
|
"{{23 -}} < {{- 45}}"
|
||||||
|
|
||||||
|
the generated output would be
|
||||||
|
|
||||||
|
"23<45"
|
||||||
|
|
||||||
|
For this trimming, the definition of white space characters is the same as in Go:
|
||||||
|
space, horizontal tab, carriage return, and newline.
|
||||||
|
|
||||||
|
Actions
|
||||||
|
|
||||||
|
Here is the list of actions. "Arguments" and "pipelines" are evaluations of
|
||||||
|
data, defined in detail in the corresponding sections that follow.
|
||||||
|
|
||||||
|
*/
|
||||||
|
// {{/* a comment */}}
|
||||||
|
// {{- /* a comment with white space trimmed from preceding and following text */ -}}
|
||||||
|
// A comment; discarded. May contain newlines.
|
||||||
|
// Comments do not nest and must start and end at the
|
||||||
|
// delimiters, as shown here.
|
||||||
|
/*
|
||||||
|
|
||||||
|
{{pipeline}}
|
||||||
|
The default textual representation (the same as would be
|
||||||
|
printed by fmt.Print) of the value of the pipeline is copied
|
||||||
|
to the output.
|
||||||
|
|
||||||
|
{{if pipeline}} T1 {{end}}
|
||||||
|
If the value of the pipeline is empty, no output is generated;
|
||||||
|
otherwise, T1 is executed. The empty values are false, 0, any
|
||||||
|
nil pointer or interface value, and any array, slice, map, or
|
||||||
|
string of length zero.
|
||||||
|
Dot is unaffected.
|
||||||
|
|
||||||
|
{{if pipeline}} T1 {{else}} T0 {{end}}
|
||||||
|
If the value of the pipeline is empty, T0 is executed;
|
||||||
|
otherwise, T1 is executed. Dot is unaffected.
|
||||||
|
|
||||||
|
{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}
|
||||||
|
To simplify the appearance of if-else chains, the else action
|
||||||
|
of an if may include another if directly; the effect is exactly
|
||||||
|
the same as writing
|
||||||
|
{{if pipeline}} T1 {{else}}{{if pipeline}} T0 {{end}}{{end}}
|
||||||
|
|
||||||
|
{{range pipeline}} T1 {{end}}
|
||||||
|
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||||
|
iter.Seq2, integer or channel.
|
||||||
|
If the value of the pipeline has length zero, nothing is output;
|
||||||
|
otherwise, dot is set to the successive elements of the array,
|
||||||
|
slice, or map and T1 is executed. If the value is a map and the
|
||||||
|
keys are of basic type with a defined order, the elements will be
|
||||||
|
visited in sorted key order.
|
||||||
|
|
||||||
|
{{range pipeline}} T1 {{else}} T0 {{end}}
|
||||||
|
The value of the pipeline must be an array, slice, map, iter.Seq,
|
||||||
|
iter.Seq2, integer or channel.
|
||||||
|
If the value of the pipeline has length zero, dot is unaffected and
|
||||||
|
T0 is executed; otherwise, dot is set to the successive elements
|
||||||
|
of the array, slice, or map and T1 is executed.
|
||||||
|
|
||||||
|
{{break}}
|
||||||
|
The innermost {{range pipeline}} loop is ended early, stopping the
|
||||||
|
current iteration and bypassing all remaining iterations.
|
||||||
|
|
||||||
|
{{continue}}
|
||||||
|
The current iteration of the innermost {{range pipeline}} loop is
|
||||||
|
stopped, and the loop starts the next iteration.
|
||||||
|
|
||||||
|
{{template "name"}}
|
||||||
|
The template with the specified name is executed with nil data.
|
||||||
|
|
||||||
|
{{template "name" pipeline}}
|
||||||
|
The template with the specified name is executed with dot set
|
||||||
|
to the value of the pipeline.
|
||||||
|
|
||||||
|
{{block "name" pipeline}} T1 {{end}}
|
||||||
|
A block is shorthand for defining a template
|
||||||
|
{{define "name"}} T1 {{end}}
|
||||||
|
and then executing it in place
|
||||||
|
{{template "name" pipeline}}
|
||||||
|
The typical use is to define a set of root templates that are
|
||||||
|
then customized by redefining the block templates within.
|
||||||
|
|
||||||
|
{{with pipeline}} T1 {{end}}
|
||||||
|
If the value of the pipeline is empty, no output is generated;
|
||||||
|
otherwise, dot is set to the value of the pipeline and T1 is
|
||||||
|
executed.
|
||||||
|
|
||||||
|
{{with pipeline}} T1 {{else}} T0 {{end}}
|
||||||
|
If the value of the pipeline is empty, dot is unaffected and T0
|
||||||
|
is executed; otherwise, dot is set to the value of the pipeline
|
||||||
|
and T1 is executed.
|
||||||
|
|
||||||
|
{{with pipeline}} T1 {{else with pipeline}} T0 {{end}}
|
||||||
|
To simplify the appearance of with-else chains, the else action
|
||||||
|
of a with may include another with directly; the effect is exactly
|
||||||
|
the same as writing
|
||||||
|
{{with pipeline}} T1 {{else}}{{with pipeline}} T0 {{end}}{{end}}
|
||||||
|
|
||||||
|
|
||||||
|
Arguments
|
||||||
|
|
||||||
|
An argument is a simple value, denoted by one of the following.
|
||||||
|
|
||||||
|
- A boolean, string, character, integer, floating-point, imaginary
|
||||||
|
or complex constant in Go syntax. These behave like Go's untyped
|
||||||
|
constants. Note that, as in Go, whether a large integer constant
|
||||||
|
overflows when assigned or passed to a function can depend on whether
|
||||||
|
the host machine's ints are 32 or 64 bits.
|
||||||
|
- The keyword nil, representing an untyped Go nil.
|
||||||
|
- The character '.' (period):
|
||||||
|
|
||||||
|
.
|
||||||
|
|
||||||
|
The result is the value of dot.
|
||||||
|
- A variable name, which is a (possibly empty) alphanumeric string
|
||||||
|
preceded by a dollar sign, such as
|
||||||
|
|
||||||
|
$piOver2
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
$
|
||||||
|
|
||||||
|
The result is the value of the variable.
|
||||||
|
Variables are described below.
|
||||||
|
- The name of a field of the data, which must be a struct, preceded
|
||||||
|
by a period, such as
|
||||||
|
|
||||||
|
.Field
|
||||||
|
|
||||||
|
The result is the value of the field. Field invocations may be
|
||||||
|
chained:
|
||||||
|
|
||||||
|
.Field1.Field2
|
||||||
|
|
||||||
|
Fields can also be evaluated on variables, including chaining:
|
||||||
|
|
||||||
|
$x.Field1.Field2
|
||||||
|
- The name of a key of the data, which must be a map, preceded
|
||||||
|
by a period, such as
|
||||||
|
|
||||||
|
.Key
|
||||||
|
|
||||||
|
The result is the map element value indexed by the key.
|
||||||
|
Key invocations may be chained and combined with fields to any
|
||||||
|
depth:
|
||||||
|
|
||||||
|
.Field1.Key1.Field2.Key2
|
||||||
|
|
||||||
|
Although the key must be an alphanumeric identifier, unlike with
|
||||||
|
field names they do not need to start with an upper case letter.
|
||||||
|
Keys can also be evaluated on variables, including chaining:
|
||||||
|
|
||||||
|
$x.key1.key2
|
||||||
|
- The name of a niladic method of the data, preceded by a period,
|
||||||
|
such as
|
||||||
|
|
||||||
|
.Method
|
||||||
|
|
||||||
|
The result is the value of invoking the method with dot as the
|
||||||
|
receiver, dot.Method(). Such a method must have one return value (of
|
||||||
|
any type) or two return values, the second of which is an error.
|
||||||
|
If it has two and the returned error is non-nil, execution terminates
|
||||||
|
and an error is returned to the caller as the value of Execute.
|
||||||
|
Method invocations may be chained and combined with fields and keys
|
||||||
|
to any depth:
|
||||||
|
|
||||||
|
.Field1.Key1.Method1.Field2.Key2.Method2
|
||||||
|
|
||||||
|
Methods can also be evaluated on variables, including chaining:
|
||||||
|
|
||||||
|
$x.Method1.Field
|
||||||
|
- The name of a niladic function, such as
|
||||||
|
|
||||||
|
fun
|
||||||
|
|
||||||
|
The result is the value of invoking the function, fun(). The return
|
||||||
|
types and values behave as in methods. Functions and function
|
||||||
|
names are described below.
|
||||||
|
- A parenthesized instance of one the above, for grouping. The result
|
||||||
|
may be accessed by a field or map key invocation.
|
||||||
|
|
||||||
|
print (.F1 arg1) (.F2 arg2)
|
||||||
|
(.StructValuedMethod "arg").Field
|
||||||
|
|
||||||
|
Arguments may evaluate to any type; if they are pointers the implementation
|
||||||
|
automatically indirects to the base type when required.
|
||||||
|
If an evaluation yields a function value, such as a function-valued
|
||||||
|
field of a struct, the function is not invoked automatically, but it
|
||||||
|
can be used as a truth value for an if action and the like. To invoke
|
||||||
|
it, use the call function, defined below.
|
||||||
|
|
||||||
|
Pipelines
|
||||||
|
|
||||||
|
A pipeline is a possibly chained sequence of "commands". A command is a simple
|
||||||
|
value (argument) or a function or method call, possibly with multiple arguments:
|
||||||
|
|
||||||
|
Argument
|
||||||
|
The result is the value of evaluating the argument.
|
||||||
|
.Method [Argument...]
|
||||||
|
The method can be alone or the last element of a chain but,
|
||||||
|
unlike methods in the middle of a chain, it can take arguments.
|
||||||
|
The result is the value of calling the method with the
|
||||||
|
arguments:
|
||||||
|
dot.Method(Argument1, etc.)
|
||||||
|
functionName [Argument...]
|
||||||
|
The result is the value of calling the function associated
|
||||||
|
with the name:
|
||||||
|
function(Argument1, etc.)
|
||||||
|
Functions and function names are described below.
|
||||||
|
|
||||||
|
A pipeline may be "chained" by separating a sequence of commands with pipeline
|
||||||
|
characters '|'. In a chained pipeline, the result of each command is
|
||||||
|
passed as the last argument of the following command. The output of the final
|
||||||
|
command in the pipeline is the value of the pipeline.
|
||||||
|
|
||||||
|
The output of a command will be either one value or two values, the second of
|
||||||
|
which has type error. If that second value is present and evaluates to
|
||||||
|
non-nil, execution terminates and the error is returned to the caller of
|
||||||
|
Execute.
|
||||||
|
|
||||||
|
Variables
|
||||||
|
|
||||||
|
A pipeline inside an action may initialize a variable to capture the result.
|
||||||
|
The initialization has syntax
|
||||||
|
|
||||||
|
$variable := pipeline
|
||||||
|
|
||||||
|
where $variable is the name of the variable. An action that declares a
|
||||||
|
variable produces no output.
|
||||||
|
|
||||||
|
Variables previously declared can also be assigned, using the syntax
|
||||||
|
|
||||||
|
$variable = pipeline
|
||||||
|
|
||||||
|
If a "range" action initializes a variable, the variable is set to the
|
||||||
|
successive elements of the iteration. Also, a "range" may declare two
|
||||||
|
variables, separated by a comma:
|
||||||
|
|
||||||
|
range $index, $element := pipeline
|
||||||
|
|
||||||
|
in which case $index and $element are set to the successive values of the
|
||||||
|
array/slice index or map key and element, respectively. Note that if there is
|
||||||
|
only one variable, it is assigned the element; this is opposite to the
|
||||||
|
convention in Go range clauses.
|
||||||
|
|
||||||
|
A variable's scope extends to the "end" action of the control structure ("if",
|
||||||
|
"with", or "range") in which it is declared, or to the end of the template if
|
||||||
|
there is no such control structure. A template invocation does not inherit
|
||||||
|
variables from the point of its invocation.
|
||||||
|
|
||||||
|
When execution begins, $ is set to the data argument passed to Execute, that is,
|
||||||
|
to the starting value of dot.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
|
||||||
|
Here are some example one-line templates demonstrating pipelines and variables.
|
||||||
|
All produce the quoted word "output":
|
||||||
|
|
||||||
|
{{"\"output\""}}
|
||||||
|
A string constant.
|
||||||
|
{{`"output"`}}
|
||||||
|
A raw string constant.
|
||||||
|
{{printf "%q" "output"}}
|
||||||
|
A function call.
|
||||||
|
{{"output" | printf "%q"}}
|
||||||
|
A function call whose final argument comes from the previous
|
||||||
|
command.
|
||||||
|
{{printf "%q" (print "out" "put")}}
|
||||||
|
A parenthesized argument.
|
||||||
|
{{"put" | printf "%s%s" "out" | printf "%q"}}
|
||||||
|
A more elaborate call.
|
||||||
|
{{"output" | printf "%s" | printf "%q"}}
|
||||||
|
A longer chain.
|
||||||
|
{{with "output"}}{{printf "%q" .}}{{end}}
|
||||||
|
A with action using dot.
|
||||||
|
{{with $x := "output" | printf "%q"}}{{$x}}{{end}}
|
||||||
|
A with action that creates and uses a variable.
|
||||||
|
{{with $x := "output"}}{{printf "%q" $x}}{{end}}
|
||||||
|
A with action that uses the variable in another action.
|
||||||
|
{{with $x := "output"}}{{$x | printf "%q"}}{{end}}
|
||||||
|
The same, but pipelined.
|
||||||
|
|
||||||
|
Functions
|
||||||
|
|
||||||
|
During execution functions are found in two function maps: first in the
|
||||||
|
template, then in the global function map. By default, no functions are defined
|
||||||
|
in the template but the Funcs method can be used to add them.
|
||||||
|
|
||||||
|
Predefined global functions are named as follows.
|
||||||
|
|
||||||
|
and
|
||||||
|
Returns the boolean AND of its arguments by returning the
|
||||||
|
first empty argument or the last argument. That is,
|
||||||
|
"and x y" behaves as "if x then y else x."
|
||||||
|
Evaluation proceeds through the arguments left to right
|
||||||
|
and returns when the result is determined.
|
||||||
|
call
|
||||||
|
Returns the result of calling the first argument, which
|
||||||
|
must be a function, with the remaining arguments as parameters.
|
||||||
|
Thus "call .X.Y 1 2" is, in Go notation, dot.X.Y(1, 2) where
|
||||||
|
Y is a func-valued field, map entry, or the like.
|
||||||
|
The first argument must be the result of an evaluation
|
||||||
|
that yields a value of function type (as distinct from
|
||||||
|
a predefined function such as print). The function must
|
||||||
|
return either one or two result values, the second of which
|
||||||
|
is of type error. If the arguments don't match the function
|
||||||
|
or the returned error value is non-nil, execution stops.
|
||||||
|
html
|
||||||
|
Returns the escaped HTML equivalent of the textual
|
||||||
|
representation of its arguments. This function is unavailable
|
||||||
|
in html/template, with a few exceptions.
|
||||||
|
index
|
||||||
|
Returns the result of indexing its first argument by the
|
||||||
|
following arguments. Thus "index x 1 2 3" is, in Go syntax,
|
||||||
|
x[1][2][3]. Each indexed item must be a map, slice, or array.
|
||||||
|
slice
|
||||||
|
slice returns the result of slicing its first argument by the
|
||||||
|
remaining arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2],
|
||||||
|
while "slice x" is x[:], "slice x 1" is x[1:], and "slice x 1 2 3"
|
||||||
|
is x[1:2:3]. The first argument must be a string, slice, or array.
|
||||||
|
js
|
||||||
|
Returns the escaped JavaScript equivalent of the textual
|
||||||
|
representation of its arguments.
|
||||||
|
len
|
||||||
|
Returns the integer length of its argument.
|
||||||
|
not
|
||||||
|
Returns the boolean negation of its single argument.
|
||||||
|
or
|
||||||
|
Returns the boolean OR of its arguments by returning the
|
||||||
|
first non-empty argument or the last argument, that is,
|
||||||
|
"or x y" behaves as "if x then x else y".
|
||||||
|
Evaluation proceeds through the arguments left to right
|
||||||
|
and returns when the result is determined.
|
||||||
|
print
|
||||||
|
An alias for fmt.Sprint
|
||||||
|
printf
|
||||||
|
An alias for fmt.Sprintf
|
||||||
|
println
|
||||||
|
An alias for fmt.Sprintln
|
||||||
|
urlquery
|
||||||
|
Returns the escaped value of the textual representation of
|
||||||
|
its arguments in a form suitable for embedding in a URL query.
|
||||||
|
This function is unavailable in html/template, with a few
|
||||||
|
exceptions.
|
||||||
|
|
||||||
|
The boolean functions take any zero value to be false and a non-zero
|
||||||
|
value to be true.
|
||||||
|
|
||||||
|
There is also a set of binary comparison operators defined as
|
||||||
|
functions:
|
||||||
|
|
||||||
|
eq
|
||||||
|
Returns the boolean truth of arg1 == arg2
|
||||||
|
ne
|
||||||
|
Returns the boolean truth of arg1 != arg2
|
||||||
|
lt
|
||||||
|
Returns the boolean truth of arg1 < arg2
|
||||||
|
le
|
||||||
|
Returns the boolean truth of arg1 <= arg2
|
||||||
|
gt
|
||||||
|
Returns the boolean truth of arg1 > arg2
|
||||||
|
ge
|
||||||
|
Returns the boolean truth of arg1 >= arg2
|
||||||
|
|
||||||
|
For simpler multi-way equality tests, eq (only) accepts two or more
|
||||||
|
arguments and compares the second and subsequent to the first,
|
||||||
|
returning in effect
|
||||||
|
|
||||||
|
arg1==arg2 || arg1==arg3 || arg1==arg4 ...
|
||||||
|
|
||||||
|
(Unlike with || in Go, however, eq is a function call and all the
|
||||||
|
arguments will be evaluated.)
|
||||||
|
|
||||||
|
The comparison functions work on any values whose type Go defines as
|
||||||
|
comparable. For basic types such as integers, the rules are relaxed:
|
||||||
|
size and exact type are ignored, so any integer value, signed or unsigned,
|
||||||
|
may be compared with any other integer value. (The arithmetic value is compared,
|
||||||
|
not the bit pattern, so all negative integers are less than all unsigned integers.)
|
||||||
|
However, as usual, one may not compare an int with a float32 and so on.
|
||||||
|
|
||||||
|
Associated templates
|
||||||
|
|
||||||
|
Each template is named by a string specified when it is created. Also, each
|
||||||
|
template is associated with zero or more other templates that it may invoke by
|
||||||
|
name; such associations are transitive and form a name space of templates.
|
||||||
|
|
||||||
|
A template may use a template invocation to instantiate another associated
|
||||||
|
template; see the explanation of the "template" action above. The name must be
|
||||||
|
that of a template associated with the template that contains the invocation.
|
||||||
|
|
||||||
|
Nested template definitions
|
||||||
|
|
||||||
|
When parsing a template, another template may be defined and associated with the
|
||||||
|
template being parsed. Template definitions must appear at the top level of the
|
||||||
|
template, much like global variables in a Go program.
|
||||||
|
|
||||||
|
The syntax of such definitions is to surround each template declaration with a
|
||||||
|
"define" and "end" action.
|
||||||
|
|
||||||
|
The define action names the template being created by providing a string
|
||||||
|
constant. Here is a simple example:
|
||||||
|
|
||||||
|
{{define "T1"}}ONE{{end}}
|
||||||
|
{{define "T2"}}TWO{{end}}
|
||||||
|
{{define "T3"}}{{template "T1"}} {{template "T2"}}{{end}}
|
||||||
|
{{template "T3"}}
|
||||||
|
|
||||||
|
This defines two templates, T1 and T2, and a third T3 that invokes the other two
|
||||||
|
when it is executed. Finally it invokes T3. If executed this template will
|
||||||
|
produce the text
|
||||||
|
|
||||||
|
ONE TWO
|
||||||
|
|
||||||
|
By construction, a template may reside in only one association. If it's
|
||||||
|
necessary to have a template addressable from multiple associations, the
|
||||||
|
template definition must be parsed multiple times to create distinct *Template
|
||||||
|
values, or must be copied with [Template.Clone] or [Template.AddParseTree].
|
||||||
|
|
||||||
|
Parse may be called multiple times to assemble the various associated templates;
|
||||||
|
see [ParseFiles], [ParseGlob], [Template.ParseFiles] and [Template.ParseGlob]
|
||||||
|
for simple ways to parse related templates stored in files.
|
||||||
|
|
||||||
|
A template may be executed directly or through [Template.ExecuteTemplate], which executes
|
||||||
|
an associated template identified by name. To invoke our example above, we
|
||||||
|
might write,
|
||||||
|
|
||||||
|
err := tmpl.Execute(os.Stdout, "no data needed")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("execution failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
or to invoke a particular template explicitly by name,
|
||||||
|
|
||||||
|
err := tmpl.ExecuteTemplate(os.Stdout, "T2", "no data needed")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("execution failed: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
package gotext
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package fmtsort provides a general stable ordering mechanism
|
||||||
|
// for maps, on behalf of the fmt and text/template packages.
|
||||||
|
// It is not guaranteed to be efficient and works only for types
|
||||||
|
// that are valid map keys.
|
||||||
|
package fmtsort
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Note: Throughout this package we avoid calling reflect.Value.Interface as
|
||||||
|
// it is not always legal to do so and it's easier to avoid the issue than to face it.
|
||||||
|
|
||||||
|
// SortedMap is a slice of KeyValue pairs that simplifies sorting
|
||||||
|
// and iterating over map entries.
|
||||||
|
//
|
||||||
|
// Each KeyValue pair contains a map key and its corresponding value.
|
||||||
|
type SortedMap []KeyValue
|
||||||
|
|
||||||
|
// KeyValue holds a single key and value pair found in a map.
|
||||||
|
type KeyValue struct {
|
||||||
|
Key, Value reflect.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort accepts a map and returns a SortedMap that has the same keys and
|
||||||
|
// values but in a stable sorted order according to the keys, modulo issues
|
||||||
|
// raised by unorderable key values such as NaNs.
|
||||||
|
//
|
||||||
|
// The ordering rules are more general than with Go's < operator:
|
||||||
|
//
|
||||||
|
// - when applicable, nil compares low
|
||||||
|
// - ints, floats, and strings order by <
|
||||||
|
// - NaN compares less than non-NaN floats
|
||||||
|
// - bool compares false before true
|
||||||
|
// - complex compares real, then imag
|
||||||
|
// - pointers compare by machine address
|
||||||
|
// - channel values compare by machine address
|
||||||
|
// - structs compare each field in turn
|
||||||
|
// - arrays compare each element in turn.
|
||||||
|
// Otherwise identical arrays compare by length.
|
||||||
|
// - interface values compare first by reflect.Type describing the concrete type
|
||||||
|
// and then by concrete value as described in the previous rules.
|
||||||
|
func Sort(mapValue reflect.Value) SortedMap {
|
||||||
|
if mapValue.Type().Kind() != reflect.Map {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Note: this code is arranged to not panic even in the presence
|
||||||
|
// of a concurrent map update. The runtime is responsible for
|
||||||
|
// yelling loudly if that happens. See issue 33275.
|
||||||
|
n := mapValue.Len()
|
||||||
|
sorted := make(SortedMap, 0, n)
|
||||||
|
iter := mapValue.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
sorted = append(sorted, KeyValue{iter.Key(), iter.Value()})
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(sorted, func(a, b KeyValue) int {
|
||||||
|
return compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare compares two values of the same type. It returns -1, 0, 1
|
||||||
|
// according to whether a > b (1), a == b (0), or a < b (-1).
|
||||||
|
// If the types differ, it returns -1.
|
||||||
|
// See the comment on Sort for the comparison rules.
|
||||||
|
func compare(aVal, bVal reflect.Value) int {
|
||||||
|
aType, bType := aVal.Type(), bVal.Type()
|
||||||
|
if aType != bType {
|
||||||
|
return -1 // No good answer possible, but don't return 0: they're not equal.
|
||||||
|
}
|
||||||
|
switch aVal.Kind() {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return cmp.Compare(aVal.Int(), bVal.Int())
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
return cmp.Compare(aVal.Uint(), bVal.Uint())
|
||||||
|
case reflect.String:
|
||||||
|
return cmp.Compare(aVal.String(), bVal.String())
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return cmp.Compare(aVal.Float(), bVal.Float())
|
||||||
|
case reflect.Complex64, reflect.Complex128:
|
||||||
|
a, b := aVal.Complex(), bVal.Complex()
|
||||||
|
if c := cmp.Compare(real(a), real(b)); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cmp.Compare(imag(a), imag(b))
|
||||||
|
case reflect.Bool:
|
||||||
|
a, b := aVal.Bool(), bVal.Bool()
|
||||||
|
switch {
|
||||||
|
case a == b:
|
||||||
|
return 0
|
||||||
|
case a:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
case reflect.Pointer, reflect.UnsafePointer:
|
||||||
|
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||||
|
case reflect.Chan:
|
||||||
|
if c, ok := nilCompare(aVal, bVal); ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return cmp.Compare(aVal.Pointer(), bVal.Pointer())
|
||||||
|
case reflect.Struct:
|
||||||
|
for i := 0; i < aVal.NumField(); i++ {
|
||||||
|
if c := compare(aVal.Field(i), bVal.Field(i)); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case reflect.Array:
|
||||||
|
for i := 0; i < aVal.Len(); i++ {
|
||||||
|
if c := compare(aVal.Index(i), bVal.Index(i)); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case reflect.Interface:
|
||||||
|
if c, ok := nilCompare(aVal, bVal); ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c := compare(reflect.ValueOf(aVal.Elem().Type()), reflect.ValueOf(bVal.Elem().Type()))
|
||||||
|
if c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return compare(aVal.Elem(), bVal.Elem())
|
||||||
|
default:
|
||||||
|
// Certain types cannot appear as keys (maps, funcs, slices), but be explicit.
|
||||||
|
panic("bad type in compare: " + aType.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nilCompare checks whether either value is nil. If not, the boolean is false.
|
||||||
|
// If either value is nil, the boolean is true and the integer is the comparison
|
||||||
|
// value. The comparison is defined to be 0 if both are nil, otherwise the one
|
||||||
|
// nil value compares low. Both arguments must represent a chan, func,
|
||||||
|
// interface, map, pointer, or slice.
|
||||||
|
func nilCompare(aVal, bVal reflect.Value) (int, bool) {
|
||||||
|
if aVal.IsNil() {
|
||||||
|
if bVal.IsNil() {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
return -1, true
|
||||||
|
}
|
||||||
|
if bVal.IsNil() {
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
// Copyright 2011 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gotext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuncMap is the type of the map defining the mapping from names to functions.
|
||||||
|
// Each function must have either a single return value, or two return values of
|
||||||
|
// which the second has type error. In that case, if the second (error)
|
||||||
|
// return value evaluates to non-nil during execution, execution terminates and
|
||||||
|
// Execute returns that error.
|
||||||
|
//
|
||||||
|
// Errors returned by Execute wrap the underlying error; call [errors.AsType] to
|
||||||
|
// unwrap them.
|
||||||
|
//
|
||||||
|
// When template execution invokes a function with an argument list, that list
|
||||||
|
// must be assignable to the function's parameter types. Functions meant to
|
||||||
|
// apply to arguments of arbitrary type can use parameters of type interface{} or
|
||||||
|
// of type [reflect.Value]. Similarly, functions meant to return a result of arbitrary
|
||||||
|
// type can return interface{} or [reflect.Value].
|
||||||
|
type FuncMap map[string]any
|
||||||
|
|
||||||
|
// builtins returns the FuncMap.
|
||||||
|
// It is not a global variable so the linker can dead code eliminate
|
||||||
|
// more when this isn't called. See golang.org/issue/36021.
|
||||||
|
// TODO: revert this back to a global map once golang.org/issue/2559 is fixed.
|
||||||
|
func builtins() FuncMap {
|
||||||
|
return FuncMap{
|
||||||
|
"and": and,
|
||||||
|
"call": emptyCall,
|
||||||
|
"html": HTMLEscaper,
|
||||||
|
"index": index,
|
||||||
|
"slice": slice,
|
||||||
|
"js": JSEscaper,
|
||||||
|
"len": length,
|
||||||
|
"not": not,
|
||||||
|
"or": or,
|
||||||
|
"print": fmt.Sprint,
|
||||||
|
"printf": fmt.Sprintf,
|
||||||
|
"println": fmt.Sprintln,
|
||||||
|
"urlquery": URLQueryEscaper,
|
||||||
|
|
||||||
|
// Comparisons
|
||||||
|
"eq": eq, // ==
|
||||||
|
"ge": ge, // >=
|
||||||
|
"gt": gt, // >
|
||||||
|
"le": le, // <=
|
||||||
|
"lt": lt, // <
|
||||||
|
"ne": ne, // !=
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinFuncs lazily computes & caches the builtinFuncs map.
|
||||||
|
var builtinFuncs = sync.OnceValue(func() map[string]reflect.Value {
|
||||||
|
funcMap := builtins()
|
||||||
|
m := make(map[string]reflect.Value, len(funcMap))
|
||||||
|
addValueFuncs(m, funcMap)
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
|
// addValueFuncs adds to values the functions in funcs, converting them to reflect.Values.
|
||||||
|
func addValueFuncs(out map[string]reflect.Value, in FuncMap) {
|
||||||
|
for name, fn := range in {
|
||||||
|
if !goodName(name) {
|
||||||
|
panic(fmt.Errorf("function name %q is not a valid identifier", name))
|
||||||
|
}
|
||||||
|
v := reflect.ValueOf(fn)
|
||||||
|
if v.Kind() != reflect.Func {
|
||||||
|
panic("value for " + name + " not a function")
|
||||||
|
}
|
||||||
|
if err := goodFunc(name, v.Type()); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
out[name] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFuncs adds to values the functions in funcs. It does no checking of the input -
|
||||||
|
// call addValueFuncs first.
|
||||||
|
func addFuncs(out, in FuncMap) {
|
||||||
|
for name, fn := range in {
|
||||||
|
out[name] = fn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goodFunc reports whether the function or method has the right result signature.
|
||||||
|
func goodFunc(name string, typ reflect.Type) error {
|
||||||
|
// We allow functions with 1 result or 2 results where the second is an error.
|
||||||
|
switch numOut := typ.NumOut(); {
|
||||||
|
case numOut == 1:
|
||||||
|
return nil
|
||||||
|
case numOut == 2 && typ.Out(1) == errorType:
|
||||||
|
return nil
|
||||||
|
case numOut == 2:
|
||||||
|
return fmt.Errorf("invalid function signature for %s: second return value should be error; is %s", name, typ.Out(1))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("function %s has %d return values; should be 1 or 2", name, typ.NumOut())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goodName reports whether the function name is a valid identifier.
|
||||||
|
func goodName(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, r := range name {
|
||||||
|
switch {
|
||||||
|
case r == '_':
|
||||||
|
case i == 0 && !unicode.IsLetter(r):
|
||||||
|
return false
|
||||||
|
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFunction looks for a function in the template, and global map.
|
||||||
|
func findFunction(name string, tmpl *Template) (v reflect.Value, isBuiltin, ok bool) {
|
||||||
|
if tmpl != nil && tmpl.common != nil {
|
||||||
|
tmpl.muFuncs.RLock()
|
||||||
|
defer tmpl.muFuncs.RUnlock()
|
||||||
|
if fn := tmpl.execFuncs[name]; fn.IsValid() {
|
||||||
|
return fn, false, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fn := builtinFuncs()[name]; fn.IsValid() {
|
||||||
|
return fn, true, true
|
||||||
|
}
|
||||||
|
return reflect.Value{}, false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareArg checks if value can be used as an argument of type argType, and
|
||||||
|
// converts an invalid value to appropriate zero if possible.
|
||||||
|
func prepareArg(value reflect.Value, argType reflect.Type) (reflect.Value, error) {
|
||||||
|
if !value.IsValid() {
|
||||||
|
if !canBeNil(argType) {
|
||||||
|
return reflect.Value{}, fmt.Errorf("value is nil; should be of type %s", argType)
|
||||||
|
}
|
||||||
|
value = reflect.Zero(argType)
|
||||||
|
}
|
||||||
|
if value.Type().AssignableTo(argType) {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if intLike(value.Kind()) && intLike(argType.Kind()) && value.Type().ConvertibleTo(argType) {
|
||||||
|
value = value.Convert(argType)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
return reflect.Value{}, fmt.Errorf("value has type %s; should be %s", value.Type(), argType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intLike(typ reflect.Kind) bool {
|
||||||
|
switch typ {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return true
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexArg checks if a reflect.Value can be used as an index, and converts it to int if possible.
|
||||||
|
func indexArg(index reflect.Value, cap int) (int, error) {
|
||||||
|
var x int64
|
||||||
|
switch index.Kind() {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
x = index.Int()
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
x = int64(index.Uint())
|
||||||
|
case reflect.Invalid:
|
||||||
|
return 0, fmt.Errorf("cannot index slice/array with nil")
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("cannot index slice/array with type %s", index.Type())
|
||||||
|
}
|
||||||
|
if x < 0 || int(x) < 0 || int(x) > cap {
|
||||||
|
return 0, fmt.Errorf("index out of range: %d", x)
|
||||||
|
}
|
||||||
|
return int(x), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexing.
|
||||||
|
|
||||||
|
// index returns the result of indexing its first argument by the following
|
||||||
|
// arguments. Thus "index x 1 2 3" is, in Go syntax, x[1][2][3]. Each
|
||||||
|
// indexed item must be a map, slice, or array.
|
||||||
|
func index(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||||
|
item = indirectInterface(item)
|
||||||
|
if !item.IsValid() {
|
||||||
|
return reflect.Value{}, fmt.Errorf("index of untyped nil")
|
||||||
|
}
|
||||||
|
for _, index := range indexes {
|
||||||
|
index = indirectInterface(index)
|
||||||
|
var isNil bool
|
||||||
|
if item, isNil = indirect(item); isNil {
|
||||||
|
return reflect.Value{}, fmt.Errorf("index of nil pointer")
|
||||||
|
}
|
||||||
|
switch item.Kind() {
|
||||||
|
case reflect.Array, reflect.Slice, reflect.String:
|
||||||
|
x, err := indexArg(index, item.Len())
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
item = item.Index(x)
|
||||||
|
case reflect.Map:
|
||||||
|
index, err := prepareArg(index, item.Type().Key())
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
if x := item.MapIndex(index); x.IsValid() {
|
||||||
|
item = x
|
||||||
|
} else {
|
||||||
|
item = reflect.Zero(item.Type().Elem())
|
||||||
|
}
|
||||||
|
case reflect.Invalid:
|
||||||
|
// the loop holds invariant: item.IsValid()
|
||||||
|
panic("unreachable")
|
||||||
|
default:
|
||||||
|
return reflect.Value{}, fmt.Errorf("can't index item of type %s", item.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slicing.
|
||||||
|
|
||||||
|
// slice returns the result of slicing its first argument by the remaining
|
||||||
|
// arguments. Thus "slice x 1 2" is, in Go syntax, x[1:2], while "slice x"
|
||||||
|
// is x[:], "slice x 1" is x[1:], and "slice x 1 2 3" is x[1:2:3]. The first
|
||||||
|
// argument must be a string, slice, or array.
|
||||||
|
func slice(item reflect.Value, indexes ...reflect.Value) (reflect.Value, error) {
|
||||||
|
item = indirectInterface(item)
|
||||||
|
if !item.IsValid() {
|
||||||
|
return reflect.Value{}, fmt.Errorf("slice of untyped nil")
|
||||||
|
}
|
||||||
|
var isNil bool
|
||||||
|
if item, isNil = indirect(item); isNil {
|
||||||
|
return reflect.Value{}, fmt.Errorf("slice of nil pointer")
|
||||||
|
}
|
||||||
|
if len(indexes) > 3 {
|
||||||
|
return reflect.Value{}, fmt.Errorf("too many slice indexes: %d", len(indexes))
|
||||||
|
}
|
||||||
|
var cap int
|
||||||
|
switch item.Kind() {
|
||||||
|
case reflect.String:
|
||||||
|
if len(indexes) == 3 {
|
||||||
|
return reflect.Value{}, fmt.Errorf("cannot 3-index slice a string")
|
||||||
|
}
|
||||||
|
cap = item.Len()
|
||||||
|
case reflect.Array, reflect.Slice:
|
||||||
|
cap = item.Cap()
|
||||||
|
default:
|
||||||
|
return reflect.Value{}, fmt.Errorf("can't slice item of type %s", item.Type())
|
||||||
|
}
|
||||||
|
// set default values for cases item[:], item[i:].
|
||||||
|
idx := [3]int{0, item.Len()}
|
||||||
|
for i, index := range indexes {
|
||||||
|
x, err := indexArg(index, cap)
|
||||||
|
if err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
idx[i] = x
|
||||||
|
}
|
||||||
|
// given item[i:j], make sure i <= j.
|
||||||
|
if idx[0] > idx[1] {
|
||||||
|
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[0], idx[1])
|
||||||
|
}
|
||||||
|
if len(indexes) < 3 {
|
||||||
|
return item.Slice(idx[0], idx[1]), nil
|
||||||
|
}
|
||||||
|
// given item[i:j:k], make sure i <= j <= k.
|
||||||
|
if idx[1] > idx[2] {
|
||||||
|
return reflect.Value{}, fmt.Errorf("invalid slice index: %d > %d", idx[1], idx[2])
|
||||||
|
}
|
||||||
|
return item.Slice3(idx[0], idx[1], idx[2]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length
|
||||||
|
|
||||||
|
// length returns the length of the item, with an error if it has no defined length.
|
||||||
|
func length(item reflect.Value) (int, error) {
|
||||||
|
item, isNil := indirect(item)
|
||||||
|
if isNil {
|
||||||
|
return 0, fmt.Errorf("len of nil pointer")
|
||||||
|
}
|
||||||
|
switch item.Kind() {
|
||||||
|
case reflect.Array, reflect.Chan, reflect.Map, reflect.Slice, reflect.String:
|
||||||
|
return item.Len(), nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("len of type %s", item.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function invocation
|
||||||
|
|
||||||
|
func emptyCall(fn reflect.Value, args ...reflect.Value) reflect.Value {
|
||||||
|
panic("unreachable") // implemented as a special case in evalCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// call returns the result of evaluating the first argument as a function.
|
||||||
|
// The function must return 1 result, or 2 results, the second of which is an error.
|
||||||
|
func call(name string, fn reflect.Value, args ...reflect.Value) (reflect.Value, error) {
|
||||||
|
fn = indirectInterface(fn)
|
||||||
|
if !fn.IsValid() {
|
||||||
|
return reflect.Value{}, fmt.Errorf("call of nil")
|
||||||
|
}
|
||||||
|
typ := fn.Type()
|
||||||
|
if typ.Kind() != reflect.Func {
|
||||||
|
return reflect.Value{}, fmt.Errorf("non-function %s of type %s", name, typ)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := goodFunc(name, typ); err != nil {
|
||||||
|
return reflect.Value{}, err
|
||||||
|
}
|
||||||
|
numIn := typ.NumIn()
|
||||||
|
var dddType reflect.Type
|
||||||
|
if typ.IsVariadic() {
|
||||||
|
if len(args) < numIn-1 {
|
||||||
|
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want at least %d", name, len(args), numIn-1)
|
||||||
|
}
|
||||||
|
dddType = typ.In(numIn - 1).Elem()
|
||||||
|
} else {
|
||||||
|
if len(args) != numIn {
|
||||||
|
return reflect.Value{}, fmt.Errorf("wrong number of args for %s: got %d want %d", name, len(args), numIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
argv := make([]reflect.Value, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
arg = indirectInterface(arg)
|
||||||
|
// Compute the expected type. Clumsy because of variadics.
|
||||||
|
argType := dddType
|
||||||
|
if !typ.IsVariadic() || i < numIn-1 {
|
||||||
|
argType = typ.In(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if argv[i], err = prepareArg(arg, argType); err != nil {
|
||||||
|
return reflect.Value{}, fmt.Errorf("arg %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safeCall(fn, argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeCall runs fun.Call(args), and returns the resulting value and error, if
|
||||||
|
// any. If the call panics, the panic value is returned as an error.
|
||||||
|
func safeCall(fun reflect.Value, args []reflect.Value) (val reflect.Value, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if e, ok := r.(error); ok {
|
||||||
|
err = e
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("%v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ret := fun.Call(args)
|
||||||
|
if len(ret) == 2 && !ret[1].IsNil() {
|
||||||
|
return ret[0], ret[1].Interface().(error)
|
||||||
|
}
|
||||||
|
return ret[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean logic.
|
||||||
|
|
||||||
|
func truth(arg reflect.Value) bool {
|
||||||
|
t, _ := isTrue(indirectInterface(arg))
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// and computes the Boolean AND of its arguments, returning
|
||||||
|
// the first false argument it encounters, or the last argument.
|
||||||
|
func and(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||||
|
panic("unreachable") // implemented as a special case in evalCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// or computes the Boolean OR of its arguments, returning
|
||||||
|
// the first true argument it encounters, or the last argument.
|
||||||
|
func or(arg0 reflect.Value, args ...reflect.Value) reflect.Value {
|
||||||
|
panic("unreachable") // implemented as a special case in evalCall
|
||||||
|
}
|
||||||
|
|
||||||
|
// not returns the Boolean negation of its argument.
|
||||||
|
func not(arg reflect.Value) bool {
|
||||||
|
return !truth(arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparison.
|
||||||
|
|
||||||
|
// TODO: Perhaps allow comparison between signed and unsigned integers.
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBadComparisonType = errors.New("invalid type for comparison")
|
||||||
|
errNoComparison = errors.New("missing argument for comparison")
|
||||||
|
)
|
||||||
|
|
||||||
|
type kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
invalidKind kind = iota
|
||||||
|
boolKind
|
||||||
|
complexKind
|
||||||
|
intKind
|
||||||
|
floatKind
|
||||||
|
stringKind
|
||||||
|
uintKind
|
||||||
|
)
|
||||||
|
|
||||||
|
func basicKind(v reflect.Value) (kind, error) {
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Bool:
|
||||||
|
return boolKind, nil
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
return intKind, nil
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||||
|
return uintKind, nil
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
return floatKind, nil
|
||||||
|
case reflect.Complex64, reflect.Complex128:
|
||||||
|
return complexKind, nil
|
||||||
|
case reflect.String:
|
||||||
|
return stringKind, nil
|
||||||
|
}
|
||||||
|
return invalidKind, errBadComparisonType
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNil returns true if v is the zero reflect.Value, or nil of its type.
|
||||||
|
func isNil(v reflect.Value) bool {
|
||||||
|
if !v.IsValid() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch v.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
return v.IsNil()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// canCompare reports whether v1 and v2 are both the same kind, or one is nil.
|
||||||
|
// Called only when dealing with nillable types, or there's about to be an error.
|
||||||
|
func canCompare(v1, v2 reflect.Value) bool {
|
||||||
|
k1 := v1.Kind()
|
||||||
|
k2 := v2.Kind()
|
||||||
|
if k1 == k2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// We know the type can be compared to nil.
|
||||||
|
return k1 == reflect.Invalid || k2 == reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// eq evaluates the comparison a == b || a == c || ...
|
||||||
|
func eq(arg1 reflect.Value, arg2 ...reflect.Value) (bool, error) {
|
||||||
|
arg1 = indirectInterface(arg1)
|
||||||
|
if len(arg2) == 0 {
|
||||||
|
return false, errNoComparison
|
||||||
|
}
|
||||||
|
k1, _ := basicKind(arg1)
|
||||||
|
for _, arg := range arg2 {
|
||||||
|
arg = indirectInterface(arg)
|
||||||
|
k2, _ := basicKind(arg)
|
||||||
|
truth := false
|
||||||
|
if k1 != k2 {
|
||||||
|
// Special case: Can compare integer values regardless of type's sign.
|
||||||
|
switch {
|
||||||
|
case k1 == intKind && k2 == uintKind:
|
||||||
|
truth = arg1.Int() >= 0 && uint64(arg1.Int()) == arg.Uint()
|
||||||
|
case k1 == uintKind && k2 == intKind:
|
||||||
|
truth = arg.Int() >= 0 && arg1.Uint() == uint64(arg.Int())
|
||||||
|
default:
|
||||||
|
if arg1.IsValid() && arg.IsValid() {
|
||||||
|
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg.Type())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch k1 {
|
||||||
|
case boolKind:
|
||||||
|
truth = arg1.Bool() == arg.Bool()
|
||||||
|
case complexKind:
|
||||||
|
truth = arg1.Complex() == arg.Complex()
|
||||||
|
case floatKind:
|
||||||
|
truth = arg1.Float() == arg.Float()
|
||||||
|
case intKind:
|
||||||
|
truth = arg1.Int() == arg.Int()
|
||||||
|
case stringKind:
|
||||||
|
truth = arg1.String() == arg.String()
|
||||||
|
case uintKind:
|
||||||
|
truth = arg1.Uint() == arg.Uint()
|
||||||
|
default:
|
||||||
|
if !canCompare(arg1, arg) {
|
||||||
|
return false, fmt.Errorf("non-comparable types %s: %v, %s: %v", arg1, arg1.Type(), arg.Type(), arg)
|
||||||
|
}
|
||||||
|
if isNil(arg1) || isNil(arg) {
|
||||||
|
truth = isNil(arg) == isNil(arg1)
|
||||||
|
} else {
|
||||||
|
if !arg.Type().Comparable() {
|
||||||
|
return false, fmt.Errorf("non-comparable type %s: %v", arg, arg.Type())
|
||||||
|
}
|
||||||
|
truth = arg1.Interface() == arg.Interface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if truth {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ne evaluates the comparison a != b.
|
||||||
|
func ne(arg1, arg2 reflect.Value) (bool, error) {
|
||||||
|
// != is the inverse of ==.
|
||||||
|
equal, err := eq(arg1, arg2)
|
||||||
|
return !equal, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// lt evaluates the comparison a < b.
|
||||||
|
func lt(arg1, arg2 reflect.Value) (bool, error) {
|
||||||
|
arg1 = indirectInterface(arg1)
|
||||||
|
k1, err := basicKind(arg1)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
arg2 = indirectInterface(arg2)
|
||||||
|
k2, err := basicKind(arg2)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
truth := false
|
||||||
|
if k1 != k2 {
|
||||||
|
// Special case: Can compare integer values regardless of type's sign.
|
||||||
|
switch {
|
||||||
|
case k1 == intKind && k2 == uintKind:
|
||||||
|
truth = arg1.Int() < 0 || uint64(arg1.Int()) < arg2.Uint()
|
||||||
|
case k1 == uintKind && k2 == intKind:
|
||||||
|
truth = arg2.Int() >= 0 && arg1.Uint() < uint64(arg2.Int())
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("incompatible types for comparison: %v and %v", arg1.Type(), arg2.Type())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch k1 {
|
||||||
|
case boolKind, complexKind:
|
||||||
|
return false, errBadComparisonType
|
||||||
|
case floatKind:
|
||||||
|
truth = arg1.Float() < arg2.Float()
|
||||||
|
case intKind:
|
||||||
|
truth = arg1.Int() < arg2.Int()
|
||||||
|
case stringKind:
|
||||||
|
truth = arg1.String() < arg2.String()
|
||||||
|
case uintKind:
|
||||||
|
truth = arg1.Uint() < arg2.Uint()
|
||||||
|
default:
|
||||||
|
panic("invalid kind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return truth, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// le evaluates the comparison <= b.
|
||||||
|
func le(arg1, arg2 reflect.Value) (bool, error) {
|
||||||
|
// <= is < or ==.
|
||||||
|
lessThan, err := lt(arg1, arg2)
|
||||||
|
if lessThan || err != nil {
|
||||||
|
return lessThan, err
|
||||||
|
}
|
||||||
|
return eq(arg1, arg2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gt evaluates the comparison a > b.
|
||||||
|
func gt(arg1, arg2 reflect.Value) (bool, error) {
|
||||||
|
// > is the inverse of <=.
|
||||||
|
lessOrEqual, err := le(arg1, arg2)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return !lessOrEqual, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ge evaluates the comparison a >= b.
|
||||||
|
func ge(arg1, arg2 reflect.Value) (bool, error) {
|
||||||
|
// >= is the inverse of <.
|
||||||
|
lessThan, err := lt(arg1, arg2)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return !lessThan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML escaping.
|
||||||
|
|
||||||
|
var (
|
||||||
|
htmlQuot = []byte(""") // shorter than """
|
||||||
|
htmlApos = []byte("'") // shorter than "'" and apos was not in HTML until HTML5
|
||||||
|
htmlAmp = []byte("&")
|
||||||
|
htmlLt = []byte("<")
|
||||||
|
htmlGt = []byte(">")
|
||||||
|
htmlNull = []byte("\uFFFD")
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLEscape writes to w the escaped HTML equivalent of the plain text data b.
|
||||||
|
func HTMLEscape(w io.Writer, b []byte) {
|
||||||
|
last := 0
|
||||||
|
for i, c := range b {
|
||||||
|
var html []byte
|
||||||
|
switch c {
|
||||||
|
case '\000':
|
||||||
|
html = htmlNull
|
||||||
|
case '"':
|
||||||
|
html = htmlQuot
|
||||||
|
case '\'':
|
||||||
|
html = htmlApos
|
||||||
|
case '&':
|
||||||
|
html = htmlAmp
|
||||||
|
case '<':
|
||||||
|
html = htmlLt
|
||||||
|
case '>':
|
||||||
|
html = htmlGt
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.Write(b[last:i])
|
||||||
|
w.Write(html)
|
||||||
|
last = i + 1
|
||||||
|
}
|
||||||
|
w.Write(b[last:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMLEscapeString returns the escaped HTML equivalent of the plain text data s.
|
||||||
|
func HTMLEscapeString(s string) string {
|
||||||
|
// Avoid allocation if we can.
|
||||||
|
if !strings.ContainsAny(s, "'\"&<>\000") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
HTMLEscape(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTMLEscaper returns the escaped HTML equivalent of the textual
|
||||||
|
// representation of its arguments.
|
||||||
|
func HTMLEscaper(args ...any) string {
|
||||||
|
return HTMLEscapeString(evalArgs(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript escaping.
|
||||||
|
|
||||||
|
var (
|
||||||
|
jsLowUni = []byte(`\u00`)
|
||||||
|
hex = []byte("0123456789ABCDEF")
|
||||||
|
|
||||||
|
jsBackslash = []byte(`\\`)
|
||||||
|
jsApos = []byte(`\'`)
|
||||||
|
jsQuot = []byte(`\"`)
|
||||||
|
jsLt = []byte(`\u003C`)
|
||||||
|
jsGt = []byte(`\u003E`)
|
||||||
|
jsAmp = []byte(`\u0026`)
|
||||||
|
jsEq = []byte(`\u003D`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// JSEscape writes to w the escaped JavaScript equivalent of the plain text data b.
|
||||||
|
func JSEscape(w io.Writer, b []byte) {
|
||||||
|
last := 0
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
c := b[i]
|
||||||
|
|
||||||
|
if !jsIsSpecial(rune(c)) {
|
||||||
|
// fast path: nothing to do
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w.Write(b[last:i])
|
||||||
|
|
||||||
|
if c < utf8.RuneSelf {
|
||||||
|
// Quotes, slashes and angle brackets get quoted.
|
||||||
|
// Control characters get written as \u00XX.
|
||||||
|
switch c {
|
||||||
|
case '\\':
|
||||||
|
w.Write(jsBackslash)
|
||||||
|
case '\'':
|
||||||
|
w.Write(jsApos)
|
||||||
|
case '"':
|
||||||
|
w.Write(jsQuot)
|
||||||
|
case '<':
|
||||||
|
w.Write(jsLt)
|
||||||
|
case '>':
|
||||||
|
w.Write(jsGt)
|
||||||
|
case '&':
|
||||||
|
w.Write(jsAmp)
|
||||||
|
case '=':
|
||||||
|
w.Write(jsEq)
|
||||||
|
default:
|
||||||
|
w.Write(jsLowUni)
|
||||||
|
t, b := c>>4, c&0x0f
|
||||||
|
w.Write(hex[t : t+1])
|
||||||
|
w.Write(hex[b : b+1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unicode rune.
|
||||||
|
r, size := utf8.DecodeRune(b[i:])
|
||||||
|
if unicode.IsPrint(r) {
|
||||||
|
w.Write(b[i : i+size])
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(w, "\\u%04X", r)
|
||||||
|
}
|
||||||
|
i += size - 1
|
||||||
|
}
|
||||||
|
last = i + 1
|
||||||
|
}
|
||||||
|
w.Write(b[last:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSEscapeString returns the escaped JavaScript equivalent of the plain text data s.
|
||||||
|
func JSEscapeString(s string) string {
|
||||||
|
// Avoid allocation if we can.
|
||||||
|
if strings.IndexFunc(s, jsIsSpecial) < 0 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
JSEscape(&b, []byte(s))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsIsSpecial(r rune) bool {
|
||||||
|
switch r {
|
||||||
|
case '\\', '\'', '"', '<', '>', '&', '=':
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r < ' ' || utf8.RuneSelf <= r
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSEscaper returns the escaped JavaScript equivalent of the textual
|
||||||
|
// representation of its arguments.
|
||||||
|
func JSEscaper(args ...any) string {
|
||||||
|
return JSEscapeString(evalArgs(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLQueryEscaper returns the escaped value of the textual representation of
|
||||||
|
// its arguments in a form suitable for embedding in a URL query.
|
||||||
|
func URLQueryEscaper(args ...any) string {
|
||||||
|
return url.QueryEscape(evalArgs(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
// evalArgs formats the list of arguments into a string. It is therefore equivalent to
|
||||||
|
//
|
||||||
|
// fmt.Sprint(args...)
|
||||||
|
//
|
||||||
|
// except that each argument is indirected (if a pointer), as required,
|
||||||
|
// using the same rules as the default string evaluation during template
|
||||||
|
// execution.
|
||||||
|
func evalArgs(args []any) string {
|
||||||
|
ok := false
|
||||||
|
var s string
|
||||||
|
// Fast path for simple common case.
|
||||||
|
if len(args) == 1 {
|
||||||
|
s, ok = args[0].(string)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
for i, arg := range args {
|
||||||
|
a, ok := printableValue(reflect.ValueOf(arg))
|
||||||
|
if ok {
|
||||||
|
args[i] = a
|
||||||
|
} // else let fmt do its thing
|
||||||
|
}
|
||||||
|
s = fmt.Sprint(args...)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Copyright 2011 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Helper functions to make constructing templates easier.
|
||||||
|
|
||||||
|
package gotext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Functions and methods to parse templates.
|
||||||
|
|
||||||
|
// Must is a helper that wraps a call to a function returning ([*Template], error)
|
||||||
|
// and panics if the error is non-nil. It is intended for use in variable
|
||||||
|
// initializations such as
|
||||||
|
//
|
||||||
|
// var t = template.Must(template.New("name").Parse("text"))
|
||||||
|
func Must(t *Template, err error) *Template {
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFiles creates a new [Template] and parses the template definitions from
|
||||||
|
// the named files. The returned template's name will have the base name and
|
||||||
|
// parsed contents of the first file. There must be at least one file.
|
||||||
|
// If an error occurs, parsing stops and the returned *Template is nil.
|
||||||
|
//
|
||||||
|
// When parsing multiple files with the same name in different directories,
|
||||||
|
// the last one mentioned will be the one that results.
|
||||||
|
// For instance, ParseFiles("a/foo", "b/foo") stores "b/foo" as the template
|
||||||
|
// named "foo", while "a/foo" is unavailable.
|
||||||
|
func ParseFiles(filenames ...string) (*Template, error) {
|
||||||
|
return parseFiles(nil, readFileOS, filenames...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFiles parses the named files and associates the resulting templates with
|
||||||
|
// t. If an error occurs, parsing stops and the returned template is nil;
|
||||||
|
// otherwise it is t. There must be at least one file.
|
||||||
|
// Since the templates created by ParseFiles are named by the base
|
||||||
|
// (see [filepath.Base]) names of the argument files, t should usually have the
|
||||||
|
// name of one of the (base) names of the files. If it does not, depending on
|
||||||
|
// t's contents before calling ParseFiles, t.Execute may fail. In that
|
||||||
|
// case use t.ExecuteTemplate to execute a valid template.
|
||||||
|
//
|
||||||
|
// When parsing multiple files with the same name in different directories,
|
||||||
|
// the last one mentioned will be the one that results.
|
||||||
|
func (t *Template) ParseFiles(filenames ...string) (*Template, error) {
|
||||||
|
t.init()
|
||||||
|
return parseFiles(t, readFileOS, filenames...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFiles is the helper for the method and function. If the argument
|
||||||
|
// template is nil, it is created from the first file.
|
||||||
|
func parseFiles(t *Template, readFile func(string) (string, []byte, error), filenames ...string) (*Template, error) {
|
||||||
|
if len(filenames) == 0 {
|
||||||
|
// Not really a problem, but be consistent.
|
||||||
|
return nil, fmt.Errorf("template: no files named in call to ParseFiles")
|
||||||
|
}
|
||||||
|
for _, filename := range filenames {
|
||||||
|
name, b, err := readFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := string(b)
|
||||||
|
// First template becomes return value if not already defined,
|
||||||
|
// and we use that one for subsequent New calls to associate
|
||||||
|
// all the templates together. Also, if this file has the same name
|
||||||
|
// as t, this file becomes the contents of t, so
|
||||||
|
// t, err := New(name).Funcs(xxx).ParseFiles(name)
|
||||||
|
// works. Otherwise we create a new template associated with t.
|
||||||
|
var tmpl *Template
|
||||||
|
if t == nil {
|
||||||
|
t = New(name)
|
||||||
|
}
|
||||||
|
if name == t.Name() {
|
||||||
|
tmpl = t
|
||||||
|
} else {
|
||||||
|
tmpl = t.New(name)
|
||||||
|
}
|
||||||
|
_, err = tmpl.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseGlob creates a new [Template] and parses the template definitions from
|
||||||
|
// the files identified by the pattern. The files are matched according to the
|
||||||
|
// semantics of [filepath.Match], and the pattern must match at least one file.
|
||||||
|
// The returned template will have the [filepath.Base] name and (parsed)
|
||||||
|
// contents of the first file matched by the pattern. ParseGlob is equivalent to
|
||||||
|
// calling [ParseFiles] with the list of files matched by the pattern.
|
||||||
|
//
|
||||||
|
// When parsing multiple files with the same name in different directories,
|
||||||
|
// the last one mentioned will be the one that results.
|
||||||
|
func ParseGlob(pattern string) (*Template, error) {
|
||||||
|
return parseGlob(nil, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseGlob parses the template definitions in the files identified by the
|
||||||
|
// pattern and associates the resulting templates with t. The files are matched
|
||||||
|
// according to the semantics of [filepath.Match], and the pattern must match at
|
||||||
|
// least one file. ParseGlob is equivalent to calling [Template.ParseFiles] with
|
||||||
|
// the list of files matched by the pattern.
|
||||||
|
//
|
||||||
|
// When parsing multiple files with the same name in different directories,
|
||||||
|
// the last one mentioned will be the one that results.
|
||||||
|
func (t *Template) ParseGlob(pattern string) (*Template, error) {
|
||||||
|
t.init()
|
||||||
|
return parseGlob(t, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGlob is the implementation of the function and method ParseGlob.
|
||||||
|
func parseGlob(t *Template, pattern string) (*Template, error) {
|
||||||
|
filenames, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(filenames) == 0 {
|
||||||
|
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||||
|
}
|
||||||
|
return parseFiles(t, readFileOS, filenames...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||||
|
// instead of the host operating system's file system.
|
||||||
|
// It accepts a list of glob patterns (see [path.Match]).
|
||||||
|
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||||
|
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||||
|
return parseFS(nil, fsys, patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFS is like [Template.ParseFiles] or [Template.ParseGlob] but reads from the file system fsys
|
||||||
|
// instead of the host operating system's file system.
|
||||||
|
// It accepts a list of glob patterns (see [path.Match]).
|
||||||
|
// (Note that most file names serve as glob patterns matching only themselves.)
|
||||||
|
func (t *Template) ParseFS(fsys fs.FS, patterns ...string) (*Template, error) {
|
||||||
|
t.init()
|
||||||
|
return parseFS(t, fsys, patterns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFS(t *Template, fsys fs.FS, patterns []string) (*Template, error) {
|
||||||
|
var filenames []string
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
list, err := fs.Glob(fsys, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, fmt.Errorf("template: pattern matches no files: %#q", pattern)
|
||||||
|
}
|
||||||
|
filenames = append(filenames, list...)
|
||||||
|
}
|
||||||
|
return parseFiles(t, readFileFS(fsys), filenames...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileOS(file string) (name string, b []byte, err error) {
|
||||||
|
name = filepath.Base(file)
|
||||||
|
b, err = os.ReadFile(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileFS(fsys fs.FS) func(string) (string, []byte, error) {
|
||||||
|
return func(file string) (name string, b []byte, err error) {
|
||||||
|
name = path.Base(file)
|
||||||
|
b, err = fs.ReadFile(fsys, file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright 2015 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// This file contains the code to handle template options.
|
||||||
|
|
||||||
|
package gotext
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// missingKeyAction defines how to respond to indexing a map with a key that is not present.
|
||||||
|
type missingKeyAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
mapInvalid missingKeyAction = iota // Return an invalid reflect.Value.
|
||||||
|
mapZeroValue // Return the zero value for the map element.
|
||||||
|
mapError // Error out
|
||||||
|
)
|
||||||
|
|
||||||
|
type option struct {
|
||||||
|
missingKey missingKeyAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option sets options for the template. Options are described by
|
||||||
|
// strings, either a simple string or "key=value". There can be at
|
||||||
|
// most one equals sign in an option string. If the option string
|
||||||
|
// is unrecognized or otherwise invalid, Option panics.
|
||||||
|
//
|
||||||
|
// Known options:
|
||||||
|
//
|
||||||
|
// missingkey: Control the behavior during execution if a map is
|
||||||
|
// indexed with a key that is not present in the map.
|
||||||
|
//
|
||||||
|
// "missingkey=default" or "missingkey=invalid"
|
||||||
|
// The default behavior: Do nothing and continue execution.
|
||||||
|
// If printed, the result of the index operation is the string
|
||||||
|
// "<no value>".
|
||||||
|
// "missingkey=zero"
|
||||||
|
// The operation returns the zero value for the map type's element.
|
||||||
|
// "missingkey=error"
|
||||||
|
// Execution stops immediately with an error.
|
||||||
|
func (t *Template) Option(opt ...string) *Template {
|
||||||
|
t.init()
|
||||||
|
for _, s := range opt {
|
||||||
|
t.setOption(s)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Template) setOption(opt string) {
|
||||||
|
if opt == "" {
|
||||||
|
panic("empty option string")
|
||||||
|
}
|
||||||
|
// key=value
|
||||||
|
if key, value, ok := strings.Cut(opt, "="); ok {
|
||||||
|
switch key {
|
||||||
|
case "missingkey":
|
||||||
|
switch value {
|
||||||
|
case "invalid", "default":
|
||||||
|
t.option.missingKey = mapInvalid
|
||||||
|
return
|
||||||
|
case "zero":
|
||||||
|
t.option.missingKey = mapZeroValue
|
||||||
|
return
|
||||||
|
case "error":
|
||||||
|
t.option.missingKey = mapError
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic("unrecognized option: " + opt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
diff -ruN a/exec.go b/exec.go
|
||||||
|
--- a/exec.go 2026-07-08 21:46:30.952555712 +0200
|
||||||
|
+++ b/exec.go 2026-07-08 21:46:30.953912265 +0200
|
||||||
|
@@ -7,12 +7,14 @@
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"text/template/parse"
|
||||||
|
+ "time"
|
||||||
|
+
|
||||||
|
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxExecDepth specifies the maximum stack depth of templates within
|
||||||
|
@@ -32,11 +34,13 @@
|
||||||
|
// template so that multiple executions of the same template
|
||||||
|
// can execute in parallel.
|
||||||
|
type state struct {
|
||||||
|
- tmpl *Template
|
||||||
|
- wr io.Writer
|
||||||
|
- node parse.Node // current node, for errors
|
||||||
|
- vars []variable // push-down stack of variable values.
|
||||||
|
- depth int // the height of the stack of executing templates.
|
||||||
|
+ tmpl *Template
|
||||||
|
+ wr io.Writer
|
||||||
|
+ node parse.Node // current node, for errors
|
||||||
|
+ vars []variable // push-down stack of variable values.
|
||||||
|
+ depth int // the height of the stack of executing templates.
|
||||||
|
+ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
|
||||||
|
+ steps int64 // ntfy: node counter for amortized deadline checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||||
|
@@ -131,6 +135,10 @@
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
+// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
|
||||||
|
+// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
|
||||||
|
+var ErrExecutionInterrupted = errors.New("template execution interrupted")
|
||||||
|
+
|
||||||
|
// errorf records an ExecError and terminates processing.
|
||||||
|
func (s *state) errorf(format string, args ...any) {
|
||||||
|
name := doublePercent(s.tmpl.Name())
|
||||||
|
@@ -214,9 +222,10 @@
|
||||||
|
value = reflect.ValueOf(data)
|
||||||
|
}
|
||||||
|
state := &state{
|
||||||
|
- tmpl: t,
|
||||||
|
- wr: wr,
|
||||||
|
- vars: []variable{{"$", value}},
|
||||||
|
+ tmpl: t,
|
||||||
|
+ wr: wr,
|
||||||
|
+ vars: []variable{{"$", value}},
|
||||||
|
+ deadline: t.deadline, // ntfy: wall-clock execution bail-out
|
||||||
|
}
|
||||||
|
if t.Tree == nil || t.Root == nil {
|
||||||
|
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||||
|
@@ -260,6 +269,11 @@
|
||||||
|
// generating output as they go.
|
||||||
|
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||||
|
s.at(node)
|
||||||
|
+ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
|
||||||
|
+ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
|
||||||
|
+ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
|
||||||
|
+ s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
|
||||||
|
+ }
|
||||||
|
switch node := node.(type) {
|
||||||
|
case *parse.ActionNode:
|
||||||
|
// Do not pop variables so they persist until next end.
|
||||||
|
diff -ruN a/template.go b/template.go
|
||||||
|
--- a/template.go 2026-07-08 21:46:30.952848382 +0200
|
||||||
|
+++ b/template.go 2026-07-08 21:46:30.953952891 +0200
|
||||||
|
@@ -9,13 +9,15 @@
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"text/template/parse"
|
||||||
|
+ "time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// common holds the information shared by related templates.
|
||||||
|
type common struct {
|
||||||
|
- tmpl map[string]*Template // Map from name to defined templates.
|
||||||
|
- muTmpl sync.RWMutex // protects tmpl
|
||||||
|
- option option
|
||||||
|
+ tmpl map[string]*Template // Map from name to defined templates.
|
||||||
|
+ muTmpl sync.RWMutex // protects tmpl
|
||||||
|
+ option option
|
||||||
|
+ deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
|
||||||
|
// We use two maps, one for parsing and one for execution.
|
||||||
|
// This separation makes the API cleaner since it doesn't
|
||||||
|
// expose reflection to the client.
|
||||||
|
@@ -49,6 +51,15 @@
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
|
+// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
|
||||||
|
+// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
|
||||||
|
+// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
|
||||||
|
+func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
|
||||||
|
+ t.init()
|
||||||
|
+ t.deadline = deadline
|
||||||
|
+ return t
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
// New allocates a new, undefined template associated with the given one and with the same
|
||||||
|
// delimiters. The association, which is transitive, allows one template to
|
||||||
|
// invoke another with a {{template}} action.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// Copyright 2011 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package gotext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"text/template/parse"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// common holds the information shared by related templates.
|
||||||
|
type common struct {
|
||||||
|
tmpl map[string]*Template // Map from name to defined templates.
|
||||||
|
muTmpl sync.RWMutex // protects tmpl
|
||||||
|
option option
|
||||||
|
deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
|
||||||
|
// We use two maps, one for parsing and one for execution.
|
||||||
|
// This separation makes the API cleaner since it doesn't
|
||||||
|
// expose reflection to the client.
|
||||||
|
muFuncs sync.RWMutex // protects parseFuncs and execFuncs
|
||||||
|
parseFuncs FuncMap
|
||||||
|
execFuncs map[string]reflect.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template is the representation of a parsed template. The *parse.Tree
|
||||||
|
// field is exported only for use by [html/template] and should be treated
|
||||||
|
// as unexported by all other clients.
|
||||||
|
type Template struct {
|
||||||
|
name string
|
||||||
|
*parse.Tree
|
||||||
|
*common
|
||||||
|
leftDelim string
|
||||||
|
rightDelim string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New allocates a new, undefined template with the given name.
|
||||||
|
func New(name string) *Template {
|
||||||
|
t := &Template{
|
||||||
|
name: name,
|
||||||
|
}
|
||||||
|
t.init()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name of the template.
|
||||||
|
func (t *Template) Name() string {
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
|
||||||
|
// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
|
||||||
|
// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
|
||||||
|
func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
|
||||||
|
t.init()
|
||||||
|
t.deadline = deadline
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// New allocates a new, undefined template associated with the given one and with the same
|
||||||
|
// delimiters. The association, which is transitive, allows one template to
|
||||||
|
// invoke another with a {{template}} action.
|
||||||
|
//
|
||||||
|
// Because associated templates share underlying data, template construction
|
||||||
|
// cannot be done safely in parallel. Once the templates are constructed, they
|
||||||
|
// can be executed in parallel.
|
||||||
|
func (t *Template) New(name string) *Template {
|
||||||
|
t.init()
|
||||||
|
nt := &Template{
|
||||||
|
name: name,
|
||||||
|
common: t.common,
|
||||||
|
leftDelim: t.leftDelim,
|
||||||
|
rightDelim: t.rightDelim,
|
||||||
|
}
|
||||||
|
return nt
|
||||||
|
}
|
||||||
|
|
||||||
|
// init guarantees that t has a valid common structure.
|
||||||
|
func (t *Template) init() {
|
||||||
|
if t.common == nil {
|
||||||
|
c := new(common)
|
||||||
|
c.tmpl = make(map[string]*Template)
|
||||||
|
c.parseFuncs = make(FuncMap)
|
||||||
|
c.execFuncs = make(map[string]reflect.Value)
|
||||||
|
t.common = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns a duplicate of the template, including all associated
|
||||||
|
// templates. The actual representation is not copied, but the name space of
|
||||||
|
// associated templates is, so further calls to [Template.Parse] in the copy will add
|
||||||
|
// templates to the copy but not to the original. Clone can be used to prepare
|
||||||
|
// common templates and use them with variant definitions for other templates
|
||||||
|
// by adding the variants after the clone is made.
|
||||||
|
func (t *Template) Clone() (*Template, error) {
|
||||||
|
nt := t.copy(nil)
|
||||||
|
nt.init()
|
||||||
|
if t.common == nil {
|
||||||
|
return nt, nil
|
||||||
|
}
|
||||||
|
nt.option = t.option
|
||||||
|
t.muTmpl.RLock()
|
||||||
|
defer t.muTmpl.RUnlock()
|
||||||
|
for k, v := range t.tmpl {
|
||||||
|
if k == t.name {
|
||||||
|
nt.tmpl[t.name] = nt
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The associated templates share nt's common structure.
|
||||||
|
tmpl := v.copy(nt.common)
|
||||||
|
nt.tmpl[k] = tmpl
|
||||||
|
}
|
||||||
|
t.muFuncs.RLock()
|
||||||
|
defer t.muFuncs.RUnlock()
|
||||||
|
maps.Copy(nt.parseFuncs, t.parseFuncs)
|
||||||
|
maps.Copy(nt.execFuncs, t.execFuncs)
|
||||||
|
return nt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy returns a shallow copy of t, with common set to the argument.
|
||||||
|
func (t *Template) copy(c *common) *Template {
|
||||||
|
return &Template{
|
||||||
|
name: t.name,
|
||||||
|
Tree: t.Tree,
|
||||||
|
common: c,
|
||||||
|
leftDelim: t.leftDelim,
|
||||||
|
rightDelim: t.rightDelim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddParseTree associates the argument parse tree with the template t, giving
|
||||||
|
// it the specified name. If the template has not been defined, this tree becomes
|
||||||
|
// its definition. If it has been defined and already has that name, the existing
|
||||||
|
// definition is replaced; otherwise a new template is created, defined, and returned.
|
||||||
|
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error) {
|
||||||
|
t.init()
|
||||||
|
t.muTmpl.Lock()
|
||||||
|
defer t.muTmpl.Unlock()
|
||||||
|
nt := t
|
||||||
|
if name != t.name {
|
||||||
|
nt = t.New(name)
|
||||||
|
}
|
||||||
|
// Even if nt == t, we need to install it in the common.tmpl map.
|
||||||
|
if t.associate(nt, tree) || nt.Tree == nil {
|
||||||
|
nt.Tree = tree
|
||||||
|
}
|
||||||
|
return nt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates returns a slice of defined templates associated with t.
|
||||||
|
func (t *Template) Templates() []*Template {
|
||||||
|
if t.common == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Return a slice so we don't expose the map.
|
||||||
|
t.muTmpl.RLock()
|
||||||
|
defer t.muTmpl.RUnlock()
|
||||||
|
m := make([]*Template, 0, len(t.tmpl))
|
||||||
|
for _, v := range t.tmpl {
|
||||||
|
m = append(m, v)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delims sets the action delimiters to the specified strings, to be used in
|
||||||
|
// subsequent calls to [Template.Parse], [Template.ParseFiles], or [Template.ParseGlob]. Nested template
|
||||||
|
// definitions will inherit the settings. An empty delimiter stands for the
|
||||||
|
// corresponding default: {{ or }}.
|
||||||
|
// The return value is the template, so calls can be chained.
|
||||||
|
func (t *Template) Delims(left, right string) *Template {
|
||||||
|
t.init()
|
||||||
|
t.leftDelim = left
|
||||||
|
t.rightDelim = right
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funcs adds the elements of the argument map to the template's function map.
|
||||||
|
// It must be called before the template is parsed.
|
||||||
|
// It panics if a value in the map is not a function with appropriate return
|
||||||
|
// type or if the name cannot be used syntactically as a function in a template.
|
||||||
|
// It is legal to overwrite elements of the map. The return value is the template,
|
||||||
|
// so calls can be chained.
|
||||||
|
func (t *Template) Funcs(funcMap FuncMap) *Template {
|
||||||
|
t.init()
|
||||||
|
t.muFuncs.Lock()
|
||||||
|
defer t.muFuncs.Unlock()
|
||||||
|
addValueFuncs(t.execFuncs, funcMap)
|
||||||
|
addFuncs(t.parseFuncs, funcMap)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the template with the given name that is associated with t.
|
||||||
|
// It returns nil if there is no such template or the template has no definition.
|
||||||
|
func (t *Template) Lookup(name string) *Template {
|
||||||
|
if t.common == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.muTmpl.RLock()
|
||||||
|
defer t.muTmpl.RUnlock()
|
||||||
|
return t.tmpl[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses text as a template body for t.
|
||||||
|
// Named template definitions ({{define ...}} or {{block ...}} statements) in text
|
||||||
|
// define additional templates associated with t and are removed from the
|
||||||
|
// definition of t itself.
|
||||||
|
//
|
||||||
|
// Templates can be redefined in successive calls to Parse.
|
||||||
|
// A template definition with a body containing only white space and comments
|
||||||
|
// is considered empty and will not replace an existing template's body.
|
||||||
|
// This allows using Parse to add new named template definitions without
|
||||||
|
// overwriting the main template body.
|
||||||
|
func (t *Template) Parse(text string) (*Template, error) {
|
||||||
|
t.init()
|
||||||
|
t.muFuncs.RLock()
|
||||||
|
trees, err := parse.Parse(t.name, text, t.leftDelim, t.rightDelim, t.parseFuncs, builtins())
|
||||||
|
t.muFuncs.RUnlock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Add the newly parsed trees, including the one for t, into our common structure.
|
||||||
|
for name, tree := range trees {
|
||||||
|
if _, err := t.AddParseTree(name, tree); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// associate installs the new template into the group of templates associated
|
||||||
|
// with t. The two are already known to share the common structure.
|
||||||
|
// The boolean return value reports whether to store this tree as t.Tree.
|
||||||
|
func (t *Template) associate(new *Template, tree *parse.Tree) bool {
|
||||||
|
if new.common != t.common {
|
||||||
|
panic("internal error: associate not common")
|
||||||
|
}
|
||||||
|
if old := t.tmpl[new.name]; old != nil && parse.IsEmptyTree(tree.Root) && old.Tree != nil {
|
||||||
|
// If a template by that name exists,
|
||||||
|
// don't replace it with an empty template.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.tmpl[new.name] = new
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,8 +20,9 @@ const (
|
|||||||
//
|
//
|
||||||
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
|
// tpl := template.New("foo").Funcs(sprig.FuncMap()))
|
||||||
//
|
//
|
||||||
// TxtFuncMap returns a 'text/template'.FuncMap
|
// TxtFuncMap returns the function map as a plain map[string]any, assignable to any text/template or
|
||||||
func TxtFuncMap() template.FuncMap {
|
// html/template FuncMap (including ntfy's vendored internal/template).
|
||||||
|
func TxtFuncMap() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
// Date functions
|
// Date functions
|
||||||
"ago": dateAgo,
|
"ago": dateAgo,
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrWriteTimeout is returned when a write timed out
|
|
||||||
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 {
|
|
||||||
writer io.Writer
|
|
||||||
timeout time.Duration
|
|
||||||
start time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTimeoutWriter creates a new TimeoutWriter
|
|
||||||
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
|
||||||
return &TimeoutWriter{
|
|
||||||
writer: w,
|
|
||||||
timeout: timeout,
|
|
||||||
start: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, ErrWriteTimeout
|
|
||||||
}
|
|
||||||
return tw.writer.Write(p)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user