Compare commits

..

23 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
binwiederhier
ba86e08ffe Release notes 2025-08-08 16:19:02 -04:00
binwiederhier
2d9e2356b1 Release notes 2025-08-08 16:13:39 -04:00
binwiederhier
fe5c844a21 Add test 2025-08-08 16:10:49 -04:00
binwiederhier
97410db301 Merge remote-tracking branch 'timofej673/main' into message-cache-lock 2025-08-08 16:06:27 -04:00
binwiederhier
887751cd5d Release notes 2025-08-08 15:34:30 -04:00
Philipp C. Heckel
044326068c Merge pull request #1420 from binwiederhier/debian-stripe
WIP: Add build flags to remove Firebase, Stripe & WebPush (for Debian packaging)
2025-08-08 21:27:22 +02:00
Philipp C. Heckel
0f166e0a1d Merge pull request #1417 from orblivion/patch-2
Typo in publish.md
2025-08-05 21:13:39 +02:00
Daniel Krol
46e423fc40 Typo in publish.md 2025-08-05 14:39:57 -04:00
timof
f8082d9481 Update message_cache.go 2025-07-30 00:12:45 +04:00
timof
d9ecee7200 Merge branch 'binwiederhier:main' into main 2025-07-28 10:37:31 +04:00
Daniel Krol
4eb7dc563c Add Ntfy for Sandstorm to integrations.md 2025-07-22 18:50:18 -04:00
timof
214f70e62f Merge branch 'binwiederhier:main' into main 2025-07-21 16:52:25 +04:00
timof
006f73af7d Update message_cache.go
Added lock in add_messages to avoid "database is locked" error
Small code reformatting
2025-07-21 12:02:06 +04:00
16 changed files with 133 additions and 54 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

@@ -1106,7 +1106,7 @@ Which will result in a notification that looks like this:
When `X-Template: yes` (aliases: `Template: yes`, `Tpl: yes`) or `?template=yes` is set, you can use Go templates in the `message` and `title` fields of your
webhook payload.
Inline templates are most useful for templated one-off messages, of if you do not control the ntfy server (e.g., if you're using ntfy.sh).
Inline templates are most useful for templated one-off messages, or if you do not control the ntfy server (e.g., if you're using ntfy.sh).
Consider using [pre-defined templates](#pre-defined-templates) or [custom templates](#custom-templates) instead,
if you control the ntfy server, as templates are much easier to maintain.
@@ -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

@@ -1468,6 +1468,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.15.0 (UNRELEASED)
**Bug fixes + maintenance:**
* 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)
**Features:**

View File

@@ -8,6 +8,7 @@ import (
"net/netip"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -36,7 +37,7 @@ const (
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
@@ -73,30 +74,30 @@ const (
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
selectMessagesByIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE mid = ?
`
selectMessagesSinceTimeQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
`
selectMessagesSinceIDQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE topic = ? AND id > ? AND published = 1
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE topic = ? AND (id > ? OR published = 0)
ORDER BY time, id
`
@@ -106,10 +107,10 @@ const (
WHERE topic = ? AND published = 1
ORDER BY time DESC, id DESC
LIMIT 1
`
`
selectMessagesDueQuery = `
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
FROM messages
WHERE time <= ? AND published = 0
ORDER BY time, id
`
@@ -283,6 +284,7 @@ type messageCache struct {
db *sql.DB
queue *util.BatchingQueue[*message]
nop bool
mu sync.Mutex
}
// newSqliteCache creates a SQLite file-backed cache
@@ -347,6 +349,8 @@ func (c *messageCache) AddMessage(m *message) error {
// addMessages synchronously stores a match of messages. If the database is locked, the transaction waits until
// SQLite's busy_timeout is exceeded before erroring out.
func (c *messageCache) addMessages(ms []*message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.nop {
return nil
}
@@ -528,6 +532,8 @@ func (c *messageCache) Message(id string) (*message, error) {
}
func (c *messageCache) MarkPublished(m *message) error {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
return err
}
@@ -573,6 +579,8 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
}
func (c *messageCache) DeleteMessages(ids ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin()
if err != nil {
return err
@@ -587,6 +595,8 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
}
func (c *messageCache) ExpireMessages(topics ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin()
if err != nil {
return err
@@ -621,6 +631,8 @@ func (c *messageCache) AttachmentsExpired() ([]string, error) {
}
func (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
c.mu.Lock()
defer c.mu.Unlock()
tx, err := c.db.Begin()
if err != nil {
return err
@@ -766,6 +778,8 @@ func readMessage(rows *sql.Rows) (*message, error) {
}
func (c *messageCache) UpdateStats(messages int64) error {
c.mu.Lock()
defer c.mu.Unlock()
_, err := c.db.Exec(updateStatsQuery, messages)
return err
}

View File

@@ -3,8 +3,10 @@ package server
import (
"database/sql"
"fmt"
"github.com/stretchr/testify/assert"
"net/netip"
"path/filepath"
"sync"
"testing"
"time"
@@ -90,6 +92,26 @@ func testCacheMessages(t *testing.T, c *messageCache) {
require.Empty(t, messages)
}
func TestSqliteCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newSqliteTestCache(t))
}
func TestMemCache_MessagesLock(t *testing.T) {
testCacheMessagesLock(t, newMemTestCache(t))
}
func testCacheMessagesLock(t *testing.T, c *messageCache) {
var wg sync.WaitGroup
for i := 0; i < 5000; i++ {
wg.Add(1)
go func() {
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
wg.Done()
}()
}
wg.Wait()
}
func TestSqliteCache_MessagesScheduled(t *testing.T) {
testCacheMessagesScheduled(t, newSqliteTestCache(t))
}

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;