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])) role := user.Role(strings.TrimSpace(parts[2]))
if !user.AllowedUsername(username) { if !user.AllowedUsername(username) {
return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine) return nil, fmt.Errorf("invalid auth-users: %s, username invalid", userLine)
} else if err := user.ValidPasswordHash(passwordHash); err != nil { } else if err := user.ValidPasswordHash(passwordHash, user.DefaultUserPasswordBcryptCost); err != nil {
return nil, fmt.Errorf("invalid auth-users: %s, %s", userLine, err.Error()) return nil, fmt.Errorf("invalid auth-users: %s, password hash invalid, %s", userLine, err.Error())
} else if !user.AllowedRole(role) { } 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) 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", name: "single user",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
expected: []*user.User{ expected: []*user.User{
{ {
Name: "alice", Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
@@ -39,19 +39,19 @@ func TestParseUsers_Success(t *testing.T) {
{ {
name: "multiple users with different roles", name: "multiple users with different roles",
input: []string{ input: []string{
"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:user", "alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user",
"bob:$2b$10$abcdefghijklmnopqrstuvwxyz:admin", "bob:$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq:admin",
}, },
expected: []*user.User{ expected: []*user.User{
{ {
Name: "alice", Name: "alice",
Hash: "$2a$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
{ {
Name: "bob", Name: "bob",
Hash: "$2b$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$jIcuBWcbxd6oW1aPvoJ5iOShzu3/UJ2kSxKbTZtDypG06nBflQagq",
Role: user.RoleAdmin, Role: user.RoleAdmin,
Provisioned: true, Provisioned: true,
}, },
@@ -64,11 +64,11 @@ func TestParseUsers_Success(t *testing.T) {
}, },
{ {
name: "user with special characters in name", 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{ expected: []*user.User{
{ {
Name: "alice.test+123@example.com", Name: "alice.test+123@example.com",
Hash: "$2y$10$abcdefghijklmnopqrstuvwxyz", Hash: "$2a$10$RYUYAsl5zOnAIp6fH7BPX.Eug0rUfEUk92r8WiVusb0VK.vGojWBe",
Role: user.RoleUser, Role: user.RoleUser,
Provisioned: true, Provisioned: true,
}, },
@@ -110,23 +110,23 @@ func TestParseUsers_Errors(t *testing.T) {
}, },
{ {
name: "invalid username", name: "invalid username",
input: []string{"alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{"alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: alice@#$%:$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", error: "invalid auth-users: alice@#$%:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user, username invalid",
}, },
{ {
name: "invalid password hash - wrong prefix", name: "invalid password hash - wrong prefix",
input: []string{"alice:plaintext:user"}, 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", name: "invalid role",
input: []string{"alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid"}, input: []string{"alice:$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:invalid"},
error: "invalid auth-users: alice:$2a$10$abcdefghijklmnopqrstuvwxyz:invalid, role invalid is not allowed, allowed roles are 'admin' or 'user'", 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", name: "empty username",
input: []string{":$2a$10$abcdefghijklmnopqrstuvwxyz:user"}, input: []string{":$2a$10$320YlQeaMghYZsvtu9jzfOQZS32FysWY/T9qu5NWqcIh.DN.u5P5S:user"},
error: "invalid auth-users: :$2a$10$abcdefghijklmnopqrstuvwxyz:user, username invalid", 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_CACHE_FILE: /var/lib/ntfy/cache.db
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_AUTH_USERS: 'phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' NTFY_AUTH_USERS: 'phil:$$2a$$10$$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin' # Must escape '$' as '$$'
NTFY_BEHIND_PROXY: true NTFY_BEHIND_PROXY: true
NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments NTFY_ATTACHMENT_CACHE_DIR: /var/lib/ntfy/attachments
NTFY_ENABLE_LOGIN: true NTFY_ENABLE_LOGIN: true

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) - [InvaderInformant](https://github.com/patricksthannon/InvaderInformant) - Script for Mac OS systems that monitors new or dropped connections to your network using ntfy (Shell)
- [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell) - [NtfyPwsh](https://github.com/ptmorris1/NtfyPwsh) - PowerShell module to help send messages to ntfy (PowerShell)
- [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service. - [ntfyrr](https://github.com/leukosaima/ntfyrr) - Currently an Overseerr webhook notification to ntfy helper service.
- [ntfy for Sandstorm](https://apps.sandstorm.io/app/c6rk81r4qk6dm3k04x1kxmyccqewhh4npuxeyg1xrpfypn2ddy0h) - ntfy app for the Sandstorm platform
- [ntfy-heartbeat-monitor](https://codeberg.org/RockWolf/ntfy-heartbeat-monitor) - Application for implementing heartbeat monitoring/alerting by utilizing ntfy
## Blog + forum posts ## Blog + forum posts

View File

@@ -3679,13 +3679,13 @@ authParam = base64_raw(authHeader) // -> QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM0
The following command will generate the appropriate value for you on *nix systems: 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: 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 ## 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 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 * 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) 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) ### 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 var err error = nil
if hashed { if hashed {
hash = password hash = password
if err := ValidPasswordHash(hash); err != nil { if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err return err
} }
} else { } else {
@@ -1434,7 +1434,7 @@ func (a *Manager) changePasswordTx(tx *sql.Tx, username, password string, hashed
var err error var err error
if hashed { if hashed {
hash = password hash = password
if err := ValidPasswordHash(hash); err != nil { if err := ValidPasswordHash(hash, a.config.BcryptCost); err != nil {
return err return err
} }
} else { } else {

View File

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

View File

@@ -249,7 +249,8 @@ var (
ErrInvalidArgument = errors.New("invalid argument") ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found") ErrUserNotFound = errors.New("user not found")
ErrUserExists = errors.New("user already exists") ErrUserExists = errors.New("user already exists")
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") ErrTierNotFound = errors.New("tier not found")
ErrTokenNotFound = errors.New("token not found") ErrTokenNotFound = errors.New("token not found")
ErrPhoneNumberNotFound = errors.New("phone number 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 // 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$") { if !strings.HasPrefix(hash, "$2a$") && !strings.HasPrefix(hash, "$2b$") && !strings.HasPrefix(hash, "$2y$") {
return ErrPasswordHashInvalid 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 return nil
} }

View File

@@ -77,7 +77,10 @@ export const maybeWithBearerAuth = (headers, token) => {
return headers; 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) => { export const maybeWithAuth = (headers, user) => {
if (user?.password) { if (user?.password) {
@@ -139,7 +142,7 @@ export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-");
export const formatShortDateTime = (timestamp, language) => export const formatShortDateTime = (timestamp, language) =>
new Intl.DateTimeFormat(getKebabCaseLangStr(language), { new Intl.DateTimeFormat(getKebabCaseLangStr(language), {
dateStyle: "short", dateStyle: "short",
timeStyle: "short", timeStyle: "short"
}).format(new Date(timestamp * 1000)); }).format(new Date(timestamp * 1000));
export const formatShortDate = (timestamp, language) => export const formatShortDate = (timestamp, language) =>
@@ -178,32 +181,32 @@ export const openUrl = (url) => {
export const sounds = { export const sounds = {
ding: { ding: {
file: ding, file: ding,
label: "Ding", label: "Ding"
}, },
juntos: { juntos: {
file: juntos, file: juntos,
label: "Juntos", label: "Juntos"
}, },
pristine: { pristine: {
file: pristine, file: pristine,
label: "Pristine", label: "Pristine"
}, },
dadum: { dadum: {
file: dadum, file: dadum,
label: "Dadum", label: "Dadum"
}, },
pop: { pop: {
file: pop, file: pop,
label: "Pop", label: "Pop"
}, },
"pop-swoosh": { "pop-swoosh": {
file: popSwoosh, file: popSwoosh,
label: "Pop swoosh", label: "Pop swoosh"
}, },
beep: { beep: {
file: beep, file: beep,
label: "Beep", label: "Beep"
}, }
}; };
export const playSound = async (id) => { export const playSound = async (id) => {
@@ -216,7 +219,7 @@ export const playSound = async (id) => {
export async function* fetchLinesIterator(fileURL, headers) { export async function* fetchLinesIterator(fileURL, headers) {
const utf8Decoder = new TextDecoder("utf-8"); const utf8Decoder = new TextDecoder("utf-8");
const response = await fetch(fileURL, { const response = await fetch(fileURL, {
headers, headers
}); });
const reader = response.body.getReader(); const reader = response.body.getReader();
let { value: chunk, done: readerDone } = await reader.read(); 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; const re = /\n|\r|\r\n/gm;
let startIndex = 0; let startIndex = 0;
for (;;) { for (; ;) {
const result = re.exec(chunk); const result = re.exec(chunk);
if (!result) { if (!result) {
if (readerDone) { if (readerDone) {
@@ -270,3 +273,21 @@ export const urlB64ToUint8Array = (base64String) => {
} }
return outputArray; 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 { ContentCopy, Public } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import routes from "./routes"; 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 accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi";
import { Pref, PrefGroup } from "./Pref"; import { Pref, PrefGroup } from "./Pref";
import db from "../app/db"; import db from "../app/db";
@@ -370,7 +370,7 @@ const PhoneNumbers = () => {
}; };
const handleCopy = (phoneNumber) => { const handleCopy = (phoneNumber) => {
navigator.clipboard.writeText(phoneNumber); copyToClipboard(phoneNumber);
setSnackOpen(true); setSnackOpen(true);
}; };
@@ -841,7 +841,7 @@ const TokensTable = (props) => {
}; };
const handleCopy = async (token) => { const handleCopy = async (token) => {
await navigator.clipboard.writeText(token); copyToClipboard(token);
setSnackOpen(true); setSnackOpen(true);
}; };

View File

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

View File

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