Compare commits

..

11 Commits

Author SHA1 Message Date
binwiederhier
ebb386af58 Release notes 2025-08-24 07:44:06 -04:00
binwiederhier
b105ed6727 Fix copy to clipboard on HTTP-only hosted sites 2025-08-24 07:42:39 -04:00
Philipp C. Heckel
965110b2c3 Merge pull request #1430 from hxtmdev/patch-1
Fix base64 snippets in Publishing
2025-08-23 10:44:17 -04:00
Daniel Höxtermann
c8ac104043 Fix base64 snippets in Publishing
-w0 is usually needed for longer outputs
2025-08-23 16:34:50 +02:00
Philipp C. Heckel
e39498702d Merge pull request #1425 from DerRockWolf/docs/integrations/heartbeat-monitor
feat(docs): add ntfy-heartbeat-monitor to integrations page
2025-08-17 08:47:26 -04:00
RockWolf
9b97067b10 feat(docs): add ntfy-heartbeat-monitor to integrations page 2025-08-17 13:44:57 +02:00
binwiederhier
5244e0be14 Fix tests 2025-08-09 10:04:57 -04:00
binwiederhier
6eb25f68ac Update password hash docs, add more validation on password hash 2025-08-09 07:34:19 -04:00
Philipp C. Heckel
efe7c3fa70 Merge pull request #1399 from orblivion/patch-1
Add Ntfy for Sandstorm to integrations.md
2025-08-08 22:21:43 +02:00
Philipp C. Heckel
ce4b2ae9a0 Merge pull request #1421 from binwiederhier/message-cache-lock
Message cache lock
2025-08-08 22:19:24 +02:00
Daniel Krol
4eb7dc563c Add Ntfy for Sandstorm to integrations.md 2025-07-22 18:50:18 -04:00
14 changed files with 80 additions and 45 deletions

View File

@@ -555,8 +555,8 @@ func parseUsers(usersRaw []string) ([]*user.User, error) {
role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
} else if err := user.ValidPasswordHash(passwordHash); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error())
} else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
} else if !user.AllowedRole(role) {
return nil, fmt.Errorf("invalid auth-users: %s, role %s is not allowed, allowed roles are 'admin' or 'user'", userLine, role)
}

View File

@@ -26,11 +26,11 @@ func TestParseUsers_Success(t *testing.T) {
}{
{
name: "single user",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user"},
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
expected: []*user.User{
{
Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz",
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser,
Provisioned: true,
},
@@ -39,19 +39,19 @@ func TestParseUsers_Success(t *testing.T) {
{
name: "multiple users with different roles",
input: []string{
"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user",
"bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin",
"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
"bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
},
expected: []*user.User{
{
Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz",
Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser,
Provisioned: true,
},
{
Name: "bob",
Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz",
Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
Role: user.RoleAdmin,
Provisioned: true,
},
@@ -64,11 +64,11 @@ func TestParseUsers_Success(t *testing.T) {
},
{
name: "user with special characters in name",
input: []string{"alice.test+123@example.com:$2y$10$abcdefghijklmnopqrstuvwxyz:user"},
input: []string{"alice.test+123@example.com:$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe:user"},
expected: []*user.User{
{
Name: "alice.test+123@example.com",
Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz",
Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
Role: user.RoleUser,
Provisioned: true,
},
@@ -110,23 +110,23 @@ func TestParseUsers_Errors(t *testing.T) {
},
{
name: "invalid username",
input: []string{"alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user"},
error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid",
input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
},
{
name: "invalid password hash - wrong prefix",
input: []string{"alice:plaintext:user"},
error: "invalid auth-users: alice:plaintext:user, password hash but be a bcrypt hash, use 'ntfy user hash' to generate",
error: "invalid auth-users: alice:plaintext:user, password hash invalid, password hash must be a bcrypt hash, use 'ntfy user hash' to generate",
},
{
name: "invalid role",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"},
error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
error: "invalid auth-users: alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'",
},
{
name: "empty username",
input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"},
error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid",
input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: :$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
},
}

View File

@@ -88,7 +88,7 @@ using Docker Compose (i.e. `docker-compose.yml`):
NTFY_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin'
NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true

View File

@@ -176,6 +176,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
## Blog + forum posts

View File

@@ -3679,13 +3679,13 @@ authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM0
The following command will generate the appropriate value for you on *nix systems:
```
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
echo -n "Basic `echo -n 'testuser:fakepassword' | base64 -w0`" | base64 -w0 | tr -d '='
```
For access tokens, you can use this instead:
```
echo -n "Bearer faketoken" | base64 | tr -d '='
echo -n "Bearer faketoken" | base64 -w0 | tr -d '='
```
## Advanced features

View File

@@ -1475,6 +1475,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
* Add mutex around message cache writes to avoid `database locked` errors ([#1397](https://github.com/binwiederhier/ntfy/pull/1397), [#1391](https://github.com/binwiederhier/ntfy/issues/1391), thanks to [@timofej673](https://github.com/timofej673))
* Add build tags `nopayments`, `nofirebase` and `nowebpush` to allow excluding external dependencies, useful for
packaging in Debian ([#1420](https://github.com/binwiederhier/ntfy/pull/1420), discussion in [#1258](https://github.com/binwiederhier/ntfy/issues/1258), thanks to [@thekhalifa](https://github.com/thekhalifa) for packaging ntfy for Debian/Ubuntu)
* Make copying tokens, phone numbers, etc. possible on HTTP ([#1432](https://github.com/binwiederhier/ntfy/pull/1432)/[#1408](https://github.com/binwiederhier/ntfy/issues/1408)/[#1295](https://github.com/binwiederhier/ntfy/issues/1295), thanks to [@EdwinKM](https://github.com/EdwinKM), [@xxl6097](https://github.com/xxl6097) for reporting)
### ntfy Android app v1.16.1 (UNRELEASED)

View File

@@ -1066,7 +1066,7 @@ func (a *Manager) addUserTx(tx *sql.Tx, username, password string, role Role, ha
var err error = nil
if hashed {
hash = password
if err := ValidPasswordHash(hash); err != nil {
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else {
@@ -1434,7 +1434,7 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
var err error
if hashed {
hash = password
if err := ValidPasswordHash(hash); err != nil {
if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err
}
} else {

View File

@@ -1162,7 +1162,7 @@ func TestManager_WithProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
@@ -1292,7 +1292,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
// Re-open the DB (second app start)
require.Nil(t, a.db.Close())
conf.Users = []*User{
{Name: "philuser", Hash: "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
{Name: "philuser", Hash: "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: RoleUser},
}
conf.Access = map[string][]*Grant{
"philuser": {
@@ -1308,7 +1308,7 @@ func TestManager_UpdateNonProvisionedUsersToProvisionedUsers(t *testing.T) {
require.Len(t, users, 2)
require.Equal(t, "philuser", users[0].Name)
require.Equal(t, RoleUser, users[0].Role)
require.Equal(t, "$2a$10$AAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.Equal(t, "$2a$10$AAAAU21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", users[0].Hash)
require.True(t, users[0].Provisioned) // Updated to provisioned!
grants, err = a.Grants("philuser")

View File

@@ -249,7 +249,8 @@ var (
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists")
ErrPasswordHashInvalid = errors.New("password hash but be a bcrypt hash, use 'ntfy user hash' to generate")
ErrPasswordHashInvalid = errors.New("password hash must be a bcrypt hash, use 'ntfy user hash' to generate")
ErrPasswordHashWeak = errors.New("password hash too weak, use 'ntfy user hash' to generate")
ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number not found")

View File

@@ -41,10 +41,16 @@ func AllowedTier(tier string) bool {
}
// ValidPasswordHash checks if the given password hash is a valid bcrypt hash
func ValidPasswordHash(hash string) error {
func ValidPasswordHash(hash string, minCost int) error {
if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return ErrPasswordHashInvalid
}
cost, err := bcrypt.Cost([]byte(hash))
if err != nil { // Check if the hash is valid (length, format, etc.)
return err
} else if cost < minCost {
return ErrPasswordHashWeak
}
return nil
}

View File

@@ -77,7 +77,10 @@ export const maybeWithBearerAuth = (headers, token) => {
return headers;
};
export const withBasicAuth = (headers, username, password) => ({ ...headers, Authorization: basicAuth(username, password) });
export const withBasicAuth = (headers, username, password) => ({
...headers,
Authorization: basicAuth(username, password)
});
export const maybeWithAuth = (headers, user) => {
if (user?.password) {
@@ -139,7 +142,7 @@ export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
dateStyle: "short",
timeStyle: "short",
timeStyle: "short"
}).format(new Date(timestamp * 1000));
export const formatShortDate = (timestamp, language) =>
@@ -178,32 +181,32 @@ export const openUrl = (url) => {
export const sounds = {
ding: {
file: ding,
label: "Ding",
label: "Ding"
},
juntos: {
file: juntos,
label: "Juntos",
label: "Juntos"
},
pristine: {
file: pristine,
label: "Pristine",
label: "Pristine"
},
dadum: {
file: dadum,
label: "Dadum",
label: "Dadum"
},
pop: {
file: pop,
label: "Pop",
label: "Pop"
},
"pop-swoosh": {
file: popSwoosh,
label: "Pop swoosh",
label: "Pop swoosh"
},
beep: {
file: beep,
label: "Beep",
},
label: "Beep"
}
};
export const playSound = async (id) => {
@@ -216,7 +219,7 @@ export const playSound = async (id) => {
export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, {
headers,
headers
});
const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read();
@@ -225,7 +228,7 @@ export async function* fetchLinesIterator(fileURL, headers) {
const re = /\n|\r|\r\n/gm;
let startIndex = 0;
for (;;) {
for (; ;) {
const result = re.exec(chunk);
if (!result) {
if (readerDone) {
@@ -270,3 +273,21 @@ export const urlB64ToUint8Array = (base64String) => {
}
return outputArray;
};
export const copyToClipboard = (text) => {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", ""); // Avoid mobile keyboards from popping up
textarea.style.position = "fixed"; // Avoid scroll jump
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return Promise.resolve();
}
};

View File

@@ -45,7 +45,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
import routes from "./routes";
import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils";
import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref";
import db from "../app/db";
@@ -370,7 +370,7 @@ const PhoneNumbers = () => {
};
const handleCopy = (phoneNumber) => {
navigator.clipboard.writeText(phoneNumber);
copyToClipboard(phoneNumber);
setSnackOpen(true);
};
@@ -841,7 +841,7 @@ const TokensTable = (props) => {
};
const handleCopy = async (token) => {
await navigator.clipboard.writeText(token);
copyToClipboard(token);
setSnackOpen(true);
};

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import StackTrace from "stacktrace-js";
import { CircularProgress, Link, Button } from "@mui/material";
import { Trans, withTranslation } from "react-i18next";
import { copyToClipboard } from "../app/utils";
class ErrorBoundaryImpl extends React.Component {
constructor(props) {
@@ -64,7 +65,7 @@ class ErrorBoundaryImpl extends React.Component {
stack += `${this.state.niceStack}\n\n`;
}
stack += `${this.state.originalStack}\n`;
navigator.clipboard.writeText(stack);
copyToClipboard(stack);
}
renderUnsupportedIndexedDB() {

View File

@@ -26,7 +26,10 @@ import { Trans, useTranslation } from "react-i18next";
import { useOutletContext } from "react-router-dom";
import { useRemark } from "react-remark";
import styled from "@emotion/styled";
import { formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags } from "../app/utils";
import {
copyToClipboard,
formatBytes, formatShortDateTime, maybeActionErrors, openUrl, shortUrl, topicShortUrl, unmatchedTags
} from "../app/utils";
import { formatMessage, formatTitle, isImage } from "../app/notificationUtils";
import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles";
import subscriptionManager from "../app/SubscriptionManager";
@@ -239,7 +242,7 @@ const NotificationItem = (props) => {
await subscriptionManager.markNotificationRead(notification.id);
};
const handleCopy = (s) => {
navigator.clipboard.writeText(s);
copyToClipboard(s);
props.onShowSnack();
};
const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000;