Compare commits
8 Commits
templating
...
templating
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c6776269 | ||
|
|
7fed392e0c | ||
|
|
4692ca7b7f | ||
|
|
af16542d02 | ||
|
|
5511812e30 | ||
|
|
547b09a7e5 | ||
|
|
b9c176ddba | ||
|
|
a04f2f9c9a |
@@ -10,7 +10,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Access_Show(t *testing.T) {
|
func TestCLI_Access_Show(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ func TestCLI_Access_Show(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
s, conf, port := newTestServerWithAuth(t)
|
s, conf, port := newTestServerWithAuth(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewYamlSourceFromFile(t *testing.T) {
|
func TestNewYamlSourceFromFile(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
filename := filepath.Join(t.TempDir(), "server.yml")
|
filename := filepath.Join(t.TempDir(), "server.yml")
|
||||||
contents := `
|
contents := `
|
||||||
# Normal options
|
# Normal options
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
testMessage := util.RandomString(10)
|
testMessage := util.RandomString(10)
|
||||||
app, _, _, _ := newTestApp()
|
app, _, _, _ := newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||||
@@ -36,7 +35,6 @@ func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||||
@@ -53,7 +51,6 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCLI_Publish_All_The_Things(t *testing.T) {
|
func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||||
|
|||||||
166
docs/publish.md
166
docs/publish.md
File diff suppressed because one or more lines are too long
@@ -1342,7 +1342,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
* You can now include a message and/or title template that will be filled with values from a JSON body, great for services that let you specify a webhook URL but do not let you change the webhook body (such as Grafana). ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`), which is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana). ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||||
|
|
||||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||||
|
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
130
docs/static/js/extra.js
vendored
130
docs/static/js/extra.js
vendored
@@ -1,99 +1,103 @@
|
|||||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||||
|
|
||||||
const savedCodeTab = localStorage.getItem('savedTab')
|
const savedCodeTab = localStorage.getItem("savedTab");
|
||||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
const codeTabs = document.querySelectorAll(".tabbed-set > input");
|
||||||
for (const tab of codeTabs) {
|
for (const tab of codeTabs) {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
const pos = current.getBoundingClientRect().top
|
const pos = current.getBoundingClientRect().top;
|
||||||
const labelContent = current.innerHTML
|
const labelContent = current.innerHTML;
|
||||||
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
|
const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label");
|
||||||
for (const label of labels) {
|
for (const label of labels) {
|
||||||
if (label.innerHTML === labelContent) {
|
if (label.innerHTML === labelContent) {
|
||||||
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
|
document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve scroll position
|
|
||||||
const delta = (current.getBoundingClientRect().top) - pos
|
|
||||||
window.scrollBy(0, delta)
|
|
||||||
|
|
||||||
// Save
|
|
||||||
localStorage.setItem('savedTab', labelContent)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Select saved tab
|
|
||||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
|
||||||
const labelContent = current.innerHTML
|
|
||||||
if (savedCodeTab === labelContent) {
|
|
||||||
tab.checked = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve scroll position
|
||||||
|
const delta = (current.getBoundingClientRect().top) - pos;
|
||||||
|
window.scrollBy(0, delta);
|
||||||
|
|
||||||
|
// Save
|
||||||
|
localStorage.setItem("savedTab", labelContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select saved tab
|
||||||
|
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||||
|
const labelContent = current.innerHTML;
|
||||||
|
if (savedCodeTab === labelContent) {
|
||||||
|
tab.checked = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox for screenshot
|
// Lightbox for screenshot
|
||||||
|
|
||||||
const lightbox = document.createElement('div');
|
const lightbox = document.createElement("div");
|
||||||
lightbox.classList.add('lightbox');
|
lightbox.classList.add("lightbox");
|
||||||
document.body.appendChild(lightbox);
|
document.body.appendChild(lightbox);
|
||||||
|
|
||||||
const showScreenshotOverlay = (e, el, group, index) => {
|
const showScreenshotOverlay = (e, el, group, index) => {
|
||||||
lightbox.classList.add('show');
|
lightbox.classList.add("show");
|
||||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
return showScreenshot(e, group, index);
|
return showScreenshot(e, group, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showScreenshot = (e, group, index) => {
|
const showScreenshot = (e, group, index) => {
|
||||||
const actualIndex = resolveScreenshotIndex(group, index);
|
const actualIndex = resolveScreenshotIndex(group, index);
|
||||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
|
lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML;
|
||||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
|
lightbox.querySelector("img").onclick = (e) => {
|
||||||
currentScreenshotGroup = group;
|
return showScreenshot(e, group, actualIndex + 1);
|
||||||
currentScreenshotIndex = actualIndex;
|
};
|
||||||
e.stopPropagation();
|
currentScreenshotGroup = group;
|
||||||
return false;
|
currentScreenshotIndex = actualIndex;
|
||||||
|
e.stopPropagation();
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshot = (e) => {
|
const nextScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const previousScreenshot = (e) => {
|
const previousScreenshot = (e) => {
|
||||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
|
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveScreenshotIndex = (group, index) => {
|
const resolveScreenshotIndex = (group, index) => {
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return screenshots[group].length - 1;
|
return screenshots[group].length - 1;
|
||||||
} else if (index > screenshots[group].length - 1) {
|
} else if (index > screenshots[group].length - 1) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return index;
|
return index;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideScreenshotOverlay = (e) => {
|
const hideScreenshotOverlay = (e) => {
|
||||||
lightbox.classList.remove('show');
|
lightbox.classList.remove("show");
|
||||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextScreenshotKeyboardListener = (e) => {
|
const nextScreenshotKeyboardListener = (e) => {
|
||||||
switch (e.keyCode) {
|
switch (e.keyCode) {
|
||||||
case 37:
|
case 37:
|
||||||
previousScreenshot(e);
|
previousScreenshot(e);
|
||||||
break;
|
break;
|
||||||
case 39:
|
case 39:
|
||||||
nextScreenshot(e);
|
nextScreenshot(e);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentScreenshotGroup = '';
|
let currentScreenshotGroup = "";
|
||||||
let currentScreenshotIndex = 0;
|
let currentScreenshotIndex = 0;
|
||||||
let screenshots = {};
|
let screenshots = {};
|
||||||
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
|
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
|
||||||
const group = sg.id;
|
const group = sg.id;
|
||||||
screenshots[group] = [...sg.querySelectorAll('a')];
|
screenshots[group] = [...sg.querySelectorAll("a")];
|
||||||
screenshots[group].forEach((el, index) => {
|
screenshots[group].forEach((el, index) => {
|
||||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
|
el.onclick = (e) => {
|
||||||
});
|
return showScreenshotOverlay(e, el, group, index);
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
lightbox.onclick = hideScreenshotOverlay;
|
lightbox.onclick = hideScreenshotOverlay;
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -35,7 +35,6 @@ require (
|
|||||||
github.com/microcosm-cc/bluemonday v1.0.26
|
github.com/microcosm-cc/bluemonday v1.0.26
|
||||||
github.com/prometheus/client_golang v1.19.0
|
github.com/prometheus/client_golang v1.19.0
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0
|
github.com/stripe/stripe-go/v74 v74.30.0
|
||||||
github.com/tidwall/gjson v1.17.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -70,8 +69,6 @@ require (
|
|||||||
github.com/prometheus/procfs v0.13.0 // indirect
|
github.com/prometheus/procfs v0.13.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
|
||||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
||||||
|
|||||||
7
go.sum
7
go.sum
@@ -143,13 +143,6 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
|
||||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||||
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
|
|
||||||
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ var (
|
|||||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||||
@@ -113,12 +113,15 @@ var (
|
|||||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil}
|
||||||
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
||||||
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
||||||
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
||||||
errHTTPBadRequestTemplatedMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "", 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}
|
||||||
errHTTPBadRequestTemplatedMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "", 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}
|
||||||
|
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
|
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/user"
|
"heckel.io/ntfy/v2/user"
|
||||||
@@ -110,8 +110,6 @@ var (
|
|||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||||
urlRegex = regexp.MustCompile(`^https?://`)
|
urlRegex = regexp.MustCompile(`^https?://`)
|
||||||
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
|
phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`)
|
||||||
templateVarRegex = regexp.MustCompile(`\${([^}]+)}`)
|
|
||||||
templateVarFormat = "${%s}"
|
|
||||||
|
|
||||||
//go:embed site
|
//go:embed site
|
||||||
webFs embed.FS
|
webFs embed.FS
|
||||||
@@ -135,6 +133,13 @@ const (
|
|||||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||||
|
templateMaxExecutionTime = 100 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
||||||
|
// are not useful, and seem potentially troublesome.
|
||||||
|
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -1095,32 +1100,42 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||||
body, err := util.Peek(body, jsonBodyBytesLimit)
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if body.LimitReached {
|
} else if body.LimitReached {
|
||||||
return errHTTPEntityTooLargeJSONBody
|
return errHTTPEntityTooLargeJSONBody
|
||||||
}
|
}
|
||||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
if !gjson.Valid(peekedBody) {
|
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||||
return errHTTPBadRequestTemplatedMessageNotJSON
|
return err
|
||||||
|
}
|
||||||
|
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
m.Message = replaceGJSONTemplate(m.Message, peekedBody)
|
|
||||||
m.Title = replaceGJSONTemplate(m.Title, peekedBody)
|
|
||||||
if len(m.Message) > s.config.MessageSizeLimit {
|
if len(m.Message) > s.config.MessageSizeLimit {
|
||||||
return errHTTPBadRequestTemplatedMessageTooLarge
|
return errHTTPBadRequestTemplateMessageTooLarge
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceGJSONTemplate(template string, source string) string {
|
func replaceTemplate(tpl string, source string) (string, error) {
|
||||||
matches := templateVarRegex.FindAllStringSubmatch(template, -1)
|
if templateDisallowedRegex.MatchString(tpl) {
|
||||||
for _, m := range matches {
|
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||||
if result := gjson.Get(source, m[1]); result.Exists() {
|
|
||||||
template = strings.ReplaceAll(template, fmt.Sprintf(templateVarFormat, m[1]), result.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return template
|
var data any
|
||||||
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
|
}
|
||||||
|
t, err := template.New("").Parse(tpl)
|
||||||
|
if err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateInvalid
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||||
|
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
34
util/timeout_writer.go
Normal file
34
util/timeout_writer.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWriteTimeout is returned when a write timed out
|
||||||
|
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
|
||||||
|
|
||||||
|
// 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, errors.New("write operation failed due to timeout since creation")
|
||||||
|
}
|
||||||
|
return tw.writer.Write(p)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user