Compare commits

...

2 Commits

Author SHA1 Message Date
binwiederhier
997e20fa3f Better error message for database-url errors 2026-03-10 21:18:34 -04:00
binwiederhier
bcd07115c2 Add tooltips for edit/delete buttons 2026-03-08 18:30:11 -04:00
5 changed files with 107 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
@@ -28,6 +29,12 @@ func Open(dsn string) (*sql.DB, error) {
if err != nil {
return nil, fmt.Errorf("invalid database URL: %w", err)
}
switch u.Scheme {
case "postgres", "postgresql":
// OK
default:
return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u))
}
q := u.Query()
maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns)
if err != nil {
@@ -61,7 +68,7 @@ func Open(dsn string) (*sql.DB, error) {
db.SetConnMaxIdleTime(connMaxIdleTime)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("ping failed: %w", err)
return nil, fmt.Errorf("database ping failed (URL: %s): %w", censorPassword(u), err)
}
return db, nil
}
@@ -79,6 +86,14 @@ func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
return v, nil
}
// censorPassword returns a string representation of the URL with the password replaced by "*****".
func censorPassword(u *url.URL) string {
if password, hasPassword := u.User.Password(); hasPassword {
return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1)
}
return u.String()
}
func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {
s := q.Get(key)
if s == "" {

53
db/pg/pg_test.go Normal file
View File

@@ -0,0 +1,53 @@
package pg
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestOpen_InvalidScheme(t *testing.T) {
_, err := Open("postgresql+psycopg2://user:pass@localhost/db")
require.Error(t, err)
require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`)
require.Contains(t, err.Error(), "*****")
require.NotContains(t, err.Error(), "pass")
}
func TestOpen_InvalidURL(t *testing.T) {
_, err := Open("not a valid url\x00")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid database URL")
}
func TestCensorPassword(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "with password",
url: "postgres://user:secret@localhost/db",
expected: "postgres://user:*****@localhost/db",
},
{
name: "without password",
url: "postgres://localhost/db",
expected: "postgres://localhost/db",
},
{
name: "user only",
url: "postgres://user@localhost/db",
expected: "postgres://user@localhost/db",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.url)
require.NoError(t, err)
require.Equal(t, tt.expected, censorPassword(u))
})
}
}

View File

@@ -1755,8 +1755,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.19.0 (UNRELEASED)
### ntfy server v2.19.x (UNRELEASED)
**Bug fixes + maintenance:**
* Throttle notification sound in web app to play at most once every 2 seconds
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)

View File

@@ -136,9 +136,11 @@ const ChangePassword = () => {
</Typography>
{!account?.provisioned ? (
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
<EditIcon />
</IconButton>
<Tooltip title={t("account_basics_password_description")}>
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
<EditIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
<span>
@@ -899,12 +901,16 @@ const TokensTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{token.token !== session.token() && !token.provisioned && (
<>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
<Tooltip title={t("account_tokens_dialog_title_edit")}>
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("account_tokens_dialog_title_delete")}>
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
<CloseIcon />
</IconButton>
</Tooltip>
</>
)}
{token.token === session.token() && (

View File

@@ -385,12 +385,16 @@ const UserTable = (props) => {
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
{(!session.exists() || user.baseUrl !== config.base_url) && (
<>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
<Tooltip title={t("prefs_users_edit_button")}>
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("prefs_users_delete_button")}>
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
<CloseIcon />
</IconButton>
</Tooltip>
</>
)}
{session.exists() && user.baseUrl === config.base_url && (
@@ -738,12 +742,16 @@ const ReservationsTable = (props) => {
/>
</Tooltip>
)}
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
<CloseIcon />
</IconButton>
<Tooltip title={t("prefs_reservations_edit_button")}>
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title={t("prefs_reservations_delete_button")}>
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
<CloseIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}