Compare commits

...

27 Commits

Author SHA1 Message Date
Philipp C. Heckel
941c43c10b Merge pull request #1622 from acress1/patch-1
Update ntfy.tedomum.net URL to ntfy.tedomum.fr
2026-03-01 12:19:44 -05:00
Philipp C. Heckel
af76aa011d Merge pull request #1629 from azrikahar/docs-config-access-token
docs: fix references in access token examples
2026-03-01 12:19:10 -05:00
azrikahar
b937b44f2d undo unrelated formatting changes 2026-02-28 15:06:37 +08:00
azrikahar
e618cf1a39 mention token in private instance example 2026-02-28 15:03:09 +08:00
azrikahar
e9cf2b5523 align backup-script user references in private instance section 2026-02-28 14:56:40 +08:00
azrikahar
c49a8179cf align backup-service user references in token via the config section 2026-02-28 14:55:25 +08:00
acress1
0d375d3a08 Update ntfy.tedomum.net URL to ntfy.tedomum.fr 2026-02-23 09:22:38 +01:00
binwiederhier
8b12bdeb3a Release notes 2026-02-22 09:07:48 -05:00
binwiederhier
4e22e7f4c8 Release notes 2026-02-21 09:58:52 -05:00
binwiederhier
cf3ae187ce Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-21 09:56:33 -05:00
Philipp C. Heckel
d19b825192 Merge pull request #1620 from uzkikh/fix/smtp-html-br-linebreaks
fix(smtp): preserve <br> line breaks in HTML emails
2026-02-21 09:56:24 -05:00
Ivan Uzkikh
2e499389fc fix(smtp): preserve <br> line breaks in HTML emails
HTML-only emails (e.g. from Synology DSM 7.3 Task Scheduler) use <br>
tags for line breaks. The existing implementation passed the raw HTML
body to bluemonday with AddSpaceWhenStrippingTag(true), which replaced
every tag including <br> with a space, causing all content to appear
on a single unreadable line.

Fix: convert <br> tags to newlines before stripping HTML, so line break
semantics are preserved in the notification body.

Resolves the gap noted in #690 ("very sub-par" HTML support).
2026-02-21 14:44:06 +01:00
Philipp C. Heckel
93cd7f99f8 Merge pull request #1618 from PanderMusubi/doc_zabbix
add zabbix-ntfy to intergration doc
2026-02-19 11:00:21 -05:00
PanderMusubi
28e85df36e add zabbix-ntfy to intergration doc 2026-02-19 15:47:49 +01:00
Philipp C. Heckel
2bc7b5217b Merge pull request #1615 from benkenawell/example/zsh-terminal
Add comment for zsh users in the terminal example
2026-02-18 20:12:49 -05:00
Benjamin Kenawell
046c0e8c79 Add comment for zsh users in the terminal example
Bash and Zsh terminals' history works slightly different. In bash, the
current command is in the history already. In zsh, the
history command does not have the currently running command in it.  But
it does set the HISTCMD variable to the entry number for the current
command. So we can load the current command with `history "$HISTCMD"` in
zsh and apply the same sed filter to get the same result as the bash
`history | tail -n1` command.

I think it makes more sense to leave this as a comment in the current
example than to create a new example, since it otherwise works the exact
same.  I've added this to my workflow locally!
2026-02-18 08:07:08 -05:00
Yaron Shahrabani
652b2097ad Translated using Weblate (Hebrew)
Currently translated at 17.4% (71 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/he/
2026-02-18 13:09:47 +01:00
binwiederhier
ceda5ec3d8 Move things in user package 2026-02-16 21:04:15 -05:00
binwiederhier
3d72845c81 Tests for user package 2026-02-16 20:49:17 -05:00
binwiederhier
0edad84d86 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-02-16 10:55:17 -05:00
Philipp C. Heckel
ddf728acd1 Merge pull request #1607 from ZebMcKayhan/main
Added SIA-Server to integration list
2026-02-16 10:49:34 -05:00
Philipp C. Heckel
b1d3671dbb Merge pull request #1610 from luneth/patch-1
docs: Clarify F-Droid flavor excludes Google Services
2026-02-16 09:46:51 -05:00
luneth
3e6b46ec0c Fix: Clarify F-Droid flavor excludes Google Services
Updated documentation to reflect that the F-Droid flavor automatically excludes Google Services dependencies [here](33a36c4b54/app/build.gradle (L82))
2026-02-16 15:43:57 +01:00
ZebMcKayhan
b16d381626 Update integrations.md
Added SIA-Server to project list.
2026-02-15 19:41:22 +01:00
Mazurky
3bd1a1ea03 Translated using Weblate (Slovak)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sk/
2026-02-13 22:09:46 +00:00
Philipp C. Heckel
7adb37b94b Merge pull request #1603 from cyqsimon/npm-override
Allow overriding `npm` binary in Makefile
2026-02-13 16:22:50 -05:00
cyqsimon
bc08819525 Allow overriding npm binary in Makefile 2026-02-12 17:57:09 +08:00
14 changed files with 810 additions and 446 deletions

View File

@@ -1,4 +1,5 @@
MAKEFLAGS := --jobs=1
NPM := npm
PYTHON := python3
PIP := pip3
VERSION := $(shell git describe --tag)
@@ -137,7 +138,7 @@ web: web-deps web-build
web-build:
cd web \
&& npm run build \
&& $(NPM) run build \
&& mv build/index.html build/app.html \
&& rm -rf ../server/site \
&& mv build ../server/site \
@@ -145,20 +146,20 @@ web-build:
../server/site/config.js
web-deps:
cd web && npm install
cd web && $(NPM) install
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && npm update
cd web && $(NPM) update
web-fmt:
cd web && npm run format
cd web && $(NPM) run format
web-fmt-check:
cd web && npm run format:check
cd web && $(NPM) run format:check
web-lint:
cd web && npm run lint
cd web && $(NPM) run lint
# Main server/client build

View File

@@ -454,7 +454,7 @@ Here's an example:
```
# Comma-separated list
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,ben:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-service:$2a$10$NKbrNb7HPMjtQXWJ0f1pouw03LDLT/WzlO9VAv44x84bRCkh19h6m:user'
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76,backup-service:tk_f099we8uzj7xi5qshzajwp6jffvkz:Backup script'
```
@@ -470,7 +470,8 @@ and access tokens in the `auth-tokens` section (see [access tokens via the confi
Here's an example that defines a single admin user `phil` with the password `mypass`, and a regular user `backup-script`
with the password `backup-script`. The admin user has full access to all topics, while regular user can only
access the `backups` topic with read-write permissions. The `auth-default-access` is set to `deny-all`, which means
access the `backups` topic with read-write permissions. `phil` has a token `tk_3gd7d2yftt4b8ixyfe9mnmro88o76`
with the label "My personal token". The `auth-default-access` is set to `deny-all`, which means
that all other users and anonymous access are denied by default.
=== "Config via /etc/ntfy/server.yml"
@@ -481,7 +482,7 @@ that all other users and anonymous access are denied by default.
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
auth-access:
- "backup-service:backups:rw"
- "backup-script:backups:rw"
auth-tokens:
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token"
```
@@ -491,7 +492,7 @@ that all other users and anonymous access are denied by default.
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
NTFY_AUTH_USERS='phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin,backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user'
NTFY_AUTH_ACCESS='backup-service:backups:rw'
NTFY_AUTH_ACCESS='backup-script:backups:rw'
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
```

View File

@@ -340,10 +340,6 @@ Then either follow the steps for building with or without Firebase.
Without Firebase, you may want to still change the default `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
if you're self-hosting the server. Then run:
```
# Remove Google dependencies (FCM)
sed -i -e '/google-services/d' build.gradle
sed -i -e '/google-services/d' app/build.gradle
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
./gradlew assembleFdroidRelease
@@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle
./gradlew bundleFdroidRelease
```
The F-Droid flavor automatically excludes Google Services dependencies.
### Build Play flavor (FCM)
!!! info
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will

View File

@@ -661,6 +661,8 @@ Add the following function and alias to your `.bashrc` or `.bash_profile`:
local token=$(< ~/.ntfy_token) # Securely read the token
local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)"
local last_command=$(history | tail -n1 | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
# for zsh users, use the same sed pattern but get the history differently.
# local last_command=$(history "$HISTCMD" | sed -e 's/^[[:space:]]*[0-9]\{1,\}[[:space:]]*//' -e 's/[;&|][[:space:]]*alert$//')
curl -s -X POST "https://n.example.dev/alerts" \
-H "Authorization: Bearer $token" \
@@ -692,4 +694,4 @@ To test failure notifications:
false; alert # Always fails (exit 1)
ls --invalid; alert # Invalid option
cat nonexistent_file; alert # File not found
```
```

View File

@@ -184,6 +184,8 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [BRun](https://github.com/cbrake/brun) - Native Linux automation platform connecting triggers to actions without containers (Go)
- [Uptime Monitor](https://uptime-monitor.org) - Self-hosted, enterprise-grade uptime monitoring and alerting system (TS)
- [send_to_ntfy_extension](https://github.com/TheDuffman85/send_to_ntfy_extension/) ⭐ - A browser extension to send the notifications to ntfy (JS)
- [SIA-Server](https://github.com/ZebMcKayhan/SIA-Server) - A light weight, self-hosted notification Server for Honywell Galaxy Flex alarm systems (Python)
- [zabbix-ntfy](https://github.com/torgrimt/zabbix-ntfy) - Zabbix server Mediatype to add support for ntfy.sh services
## Blog + forum posts
@@ -304,7 +306,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
| URL | Country |
|---------------------------------------------------|--------------------|
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
| [ntfy.tedomum.net](https://ntfy.tedomum.net/) | 🇫🇷 France |
| [ntfy.tedomum.fr](https://ntfy.tedomum.fr/) | 🇫🇷 France |
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |

View File

@@ -7,11 +7,32 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.17.0 | Feb 8, 2026 |
| ntfy Android app | v1.22.2 | Jan 25, 2026 |
| ntfy Android app | v1.23.0 | Deb 22, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
## ntfy Android v1.23.0
Released February 22, 2026
This release adds support for search within a topic, and adds [copy action](publish.md#copy-to-clipboard) support
to the Android app.
**Features:**
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
**Bug fixes + maintenance:**
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
## ntfy server v2.17.0
Released February 8, 2026
@@ -1698,19 +1719,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy Android v1.23.x (UNRELEASED)
**Features:**
* Search within a topic ([#141](https://github.com/binwiederhier/ntfy/issues/141), [ntfy-android#153](https://github.com/binwiederhier/ntfy-android/pull/153), thanks to [@Copephobia](https://github.com/Copephobia) and [@StoyanYonkov](https://github.com/StoyanYonkov) for reporting and sponsoring)
* Add "reconnecting to N topics ..." to foreground notification ([#1101](https://github.com/binwiederhier/ntfy/issues/1101), thanks to [@milosivanovic](https://github.com/milosivanovic) for reporting)
* Improved default server dialog with full-screen UI and stricter URL validation ([#1582](https://github.com/binwiederhier/ntfy/issues/1582))
* Show last notification time for UnifiedPush subscriptions ([#1230](https://github.com/binwiederhier/ntfy/issues/1230), [#1454](https://github.com/binwiederhier/ntfy/issues/1454), thanks to [@Tealk](https://github.com/Tealk) and [@user4andre](https://github.com/user4andre) for reporting)
* Support "copy" action button to copy a value to the clipboard ([#1364](https://github.com/binwiederhier/ntfy/issues/1364), thanks to [@SudoWatson](https://github.com/SudoWatson) for reporting)
### ntfy server v2.18.x (UNRELEASED)
**Bug fixes + maintenance:**
* Fix `clear=true` on action buttons not marking notification as read ([#1029](https://github.com/binwiederhier/ntfy/issues/1029), thanks to [@ElFishi](https://github.com/ElFishi) for reporting)
* Fix crash when default server URL is missing scheme by auto-prepending `https://` ([#1582](https://github.com/binwiederhier/ntfy/issues/1582), thanks to [@hard-zero1](https://github.com/hard-zero1))
* Fix notification timestamp to use original send time instead of receive time ([#1112](https://github.com/binwiederhier/ntfy/issues/1112), thanks to [@voruti](https://github.com/voruti) for reporting)
* Fix notifications being missed after service restart by using persisted lastNotificationId ([#1591](https://github.com/binwiederhier/ntfy/issues/1591), thanks to @Epifeny for reporting)
* Preserve `<br>` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting)

View File

@@ -33,6 +33,7 @@ var (
var (
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
htmlLineBreakRegex = regexp.MustCompile(`(?i)<br\s*/?>`)
)
const (
@@ -327,6 +328,9 @@ func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error)
if err != nil {
return "", err
}
// Convert <br> tags to newlines before stripping HTML, so that line breaks
// in HTML emails (e.g. from Synology DSM, and other appliances) are preserved.
body = htmlLineBreakRegex.ReplaceAllString(body, "\n")
stripped := bluemonday.
StrictPolicy().
AddSpaceWhenStrippingTag(true).

View File

@@ -694,7 +694,8 @@ home automation setup
Now the light is on
If you don&#39;t want to receive this message anymore, stop the push
services in your FRITZ!Box .
services in your FRITZ!Box .
Here you can see the active push services: &#34;System &gt; Push Service&#34;.
This mail has ben sent by your FRITZ!Box automatically.`
@@ -1354,9 +1355,11 @@ Congratulations! You have successfully set up the email notification on Synology
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/synology", r.URL.Path)
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
actual := readAll(t, r.Body)
expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS`
require.Equal(t, expected, actual)
expected := "Congratulations! You have successfully set up the email notification on Synology_NAS.\n" +
"For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\n" +
"(If you cannot connect to the server, please contact the administrator.)\n\n" +
"From Synology_NAS"
require.Equal(t, expected, readAll(t, r.Body))
})
conf.SMTPServerDomain = "mydomain.me"
conf.SMTPServerAddrPrefix = ""
@@ -1365,6 +1368,36 @@ Congratulations! You have successfully set up the email notification on Synology
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_HTMLEmail_BrTagsPreserved(t *testing.T) {
email := `EHLO example.com
MAIL FROM: nas@example.com
RCPT TO: ntfy-alerts@ntfy.sh
DATA
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 8bit
Subject: Task Scheduler: daily-backup
Task Scheduler has completed a scheduled task.<BR><BR>Task: daily-backup<BR>Start time: Mon, 01 Jan 2026 02:00:00 +0000<BR>Stop time: Mon, 01 Jan 2024 02:03:00 +0000<BR>Current status: 0 (Normal)<BR>Standard output/error:<BR>OK<BR><BR>From MyNAS
.
`
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/alerts", r.URL.Path)
require.Equal(t, "Task Scheduler: daily-backup", r.Header.Get("Title"))
expected := "Task Scheduler has completed a scheduled task.\n\n" +
"Task: daily-backup\n" +
"Start time: Mon, 01 Jan 2026 02:00:00 +0000\n" +
"Stop time: Mon, 01 Jan 2024 02:03:00 +0000\n" +
"Current status: 0 (Normal)\n" +
"Standard output/error:\n" +
"OK\n\n" +
"From MyNAS"
require.Equal(t, expected, readAll(t, r.Body))
})
defer s.Close()
defer c.Close()
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
}
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
email := `EHLO example.com
MAIL FROM: phil@example.com

View File

@@ -6,17 +6,17 @@ import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"path/filepath"
"slices"
"sync"
"time"
"github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/util"
"net/netip"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
const (
@@ -326,229 +326,6 @@ const (
`
)
// Schema management queries
const (
currentSchemaVersion = 6
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 1 -> 2 (complex migration!)
migrate1To2CreateTablesQueries = `
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
`
migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
migrate1To2InsertUserNoTx = `
INSERT INTO user (id, user, pass, role, sync_topic, created)
SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ?
`
migrate1To2InsertFromOldTablesAndDropNoTx = `
INSERT INTO user_access (user_id, topic, read, write)
SELECT u.id, a.topic, a.read, a.write
FROM user u
JOIN access a ON u.user = a.user;
DROP TABLE access;
DROP TABLE user_old;
`
// 2 -> 3
migrate2To3UpdateQueries = `
ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;
ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;
ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;
DROP INDEX IF EXISTS idx_tier_price_id;
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
`
// 3 -> 4
migrate3To4UpdateQueries = `
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
// 4 -> 5
migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 5 -> 6
migrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0, -- provisioned
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created,
deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Alter user_token table: Add provisioned column
ALTER TABLE user_token RENAME TO user_token_old;
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_token SELECT *, 0 FROM user_token_old;
DROP TABLE user_token_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
)
var (
migrations = map[int]func(db *sql.DB) error{
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
}
)
// Manager is an implementation of Manager. It stores users and access control list
// in a SQLite database.
type Manager struct {
@@ -1840,7 +1617,7 @@ func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, e
return nil
}
// maybyProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config.
// maybeProvisionGrants removes all provisioned grants, and (re-)adds the grants from the config.
//
// Unlike users and tokens, grants can be just re-added, because they do not carry any state (such as last
// access time) or do not have dependent resources (such as grants or tokens).
@@ -1909,26 +1686,6 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string)
return nil
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string {
return escapeUnderscore(strings.ReplaceAll(s, "*", "%"))
}
// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*',
// and removes the '\_' escape character.
func fromSQLWildcard(s string) string {
return strings.ReplaceAll(unescapeUnderscore(s), "%", "*")
}
func escapeUnderscore(s string) string {
return strings.ReplaceAll(s, "_", "\\_")
}
func unescapeUnderscore(s string) string {
return strings.ReplaceAll(s, "\\_", "_")
}
func runStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
@@ -1983,161 +1740,3 @@ func setupNewDB(db *sql.DB) error {
}
return nil
}
func migrateFrom1(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(migrate1To2SelectAllOldUsernamesNoTx)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 2); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func migrateFrom2(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom3(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom4(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
func nullInt64(v int64) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: v, Valid: true}
}
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// queryTx executes a function in a transaction and returns the result. If the function
// returns an error, the transaction is rolled back.
func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}

342
user/migrations.go Normal file
View File

@@ -0,0 +1,342 @@
package user
import (
"database/sql"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
// Schema management queries
const (
currentSchemaVersion = 6
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 1 -> 2 (complex migration!)
migrate1To2CreateTablesQueries = `
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit INT NOT NULL,
messages_expiry_duration INT NOT NULL,
emails_limit INT NOT NULL,
reservations_limit INT NOT NULL,
attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL,
attachment_bandwidth_limit INT NOT NULL,
stripe_price_id TEXT
);
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
CREATE UNIQUE INDEX idx_tier_price_id ON tier (stripe_price_id);
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING;
`
migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
migrate1To2InsertUserNoTx = `
INSERT INTO user (id, user, pass, role, sync_topic, created)
SELECT ?, user, pass, role, ?, UNIXEPOCH() FROM user_old WHERE user = ?
`
migrate1To2InsertFromOldTablesAndDropNoTx = `
INSERT INTO user_access (user_id, topic, read, write)
SELECT u.id, a.topic, a.read, a.write
FROM user u
JOIN access a ON u.user = a.user;
DROP TABLE access;
DROP TABLE user_old;
`
// 2 -> 3
migrate2To3UpdateQueries = `
ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;
ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;
ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;
DROP INDEX IF EXISTS idx_tier_price_id;
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
`
// 3 -> 4
migrate3To4UpdateQueries = `
ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0);
ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
// 4 -> 5
migrate4To5UpdateQueries = `
UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
`
// 5 -> 6
migrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY,
tier_id TEXT,
user TEXT NOT NULL,
pass TEXT NOT NULL,
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
prefs JSON NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned INT NOT NULL,
stats_messages INT NOT NULL DEFAULT (0),
stats_emails INT NOT NULL DEFAULT (0),
stats_calls INT NOT NULL DEFAULT (0),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until INT,
stripe_subscription_cancel_at INT,
created INT NOT NULL,
deleted INT,
FOREIGN KEY (tier_id) REFERENCES tier (id)
);
INSERT INTO user
SELECT
id,
tier_id,
user,
pass,
role,
prefs,
sync_topic,
0, -- provisioned
stats_messages,
stats_emails,
stats_calls,
stripe_customer_id,
stripe_subscription_id,
stripe_subscription_status,
stripe_subscription_interval,
stripe_subscription_paid_until,
stripe_subscription_cancel_at,
created,
deleted
FROM user_old;
DROP TABLE user_old;
-- Alter user_access table: Add provisioned column
ALTER TABLE user_access RENAME TO user_access_old;
CREATE TABLE user_access (
user_id TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
owner_user_id INT,
provisioned INTEGER NOT NULL,
PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_access SELECT *, 0 FROM user_access_old;
DROP TABLE user_access_old;
-- Alter user_token table: Add provisioned column
ALTER TABLE user_token RENAME TO user_token_old;
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
label TEXT NOT NULL,
last_access INT NOT NULL,
last_origin TEXT NOT NULL,
expires INT NOT NULL,
provisioned INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_token SELECT *, 0 FROM user_token_old;
DROP TABLE user_token_old;
-- Recreate indices
CREATE UNIQUE INDEX idx_user ON user (user);
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token);
-- Re-enable foreign keys
PRAGMA foreign_keys=on;
`
)
var (
migrations = map[int]func(db *sql.DB) error{
1: migrateFrom1,
2: migrateFrom2,
3: migrateFrom3,
4: migrateFrom4,
5: migrateFrom5,
}
)
func migrateFrom1(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Rename user -> user_old, and create new tables
if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil {
return err
}
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(migrate1To2SelectAllOldUsernamesNoTx)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(migrate1To2InsertUserNoTx, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(migrate1To2InsertFromOldTablesAndDropNoTx); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 2); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func migrateFrom2(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom3(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom4(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 5); err != nil {
return err
}
return tx.Commit()
}
func migrateFrom5(db *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(migrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(updateSchemaVersion, 6); err != nil {
return err
}
return tx.Commit()
}

View File

@@ -1,10 +1,12 @@
package user
import (
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util"
"database/sql"
"regexp"
"strings"
"golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/util"
)
var (
@@ -77,3 +79,69 @@ func hashPassword(password string, cost int) (string, error) {
}
return string(hash), nil
}
func nullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
func nullInt64(v int64) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: v, Valid: true}
}
// execTx executes a function in a transaction. If the function returns an error, the transaction is rolled back.
func execTx(db *sql.DB, f func(tx *sql.Tx) error) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := f(tx); err != nil {
return err
}
return tx.Commit()
}
// queryTx executes a function in a transaction and returns the result. If the function
// returns an error, the transaction is rolled back.
func queryTx[T any](db *sql.DB, f func(tx *sql.Tx) (T, error)) (T, error) {
tx, err := db.Begin()
if err != nil {
var zero T
return zero, err
}
defer tx.Rollback()
t, err := f(tx)
if err != nil {
return t, err
}
if err := tx.Commit(); err != nil {
return t, err
}
return t, nil
}
// toSQLWildcard converts a wildcard string to a SQL wildcard string. It only allows '*' as wildcards,
// and escapes '_', assuming '\' as escape character.
func toSQLWildcard(s string) string {
return escapeUnderscore(strings.ReplaceAll(s, "*", "%"))
}
// fromSQLWildcard converts a SQL wildcard string to a wildcard string. It converts '%' to '*',
// and removes the '\_' escape character.
func fromSQLWildcard(s string) string {
return strings.ReplaceAll(unescapeUnderscore(s), "%", "*")
}
func escapeUnderscore(s string) string {
return strings.ReplaceAll(s, "_", "\\_")
}
func unescapeUnderscore(s string) string {
return strings.ReplaceAll(s, "\\_", "_")
}

281
user/util_test.go Normal file
View File

@@ -0,0 +1,281 @@
package user
import (
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func TestAllowedRole(t *testing.T) {
require.True(t, AllowedRole(RoleUser))
require.True(t, AllowedRole(RoleAdmin))
require.False(t, AllowedRole(RoleAnonymous))
require.False(t, AllowedRole(Role("invalid")))
require.False(t, AllowedRole(Role("")))
require.False(t, AllowedRole(Role("superadmin")))
}
func TestAllowedTopic(t *testing.T) {
// Valid topics
require.True(t, AllowedTopic("test"))
require.True(t, AllowedTopic("mytopic"))
require.True(t, AllowedTopic("topic123"))
require.True(t, AllowedTopic("my-topic"))
require.True(t, AllowedTopic("my_topic"))
require.True(t, AllowedTopic("Topic123"))
require.True(t, AllowedTopic("a"))
require.True(t, AllowedTopic(strings.Repeat("a", 64))) // Max length
// Invalid topics - wildcards not allowed
require.False(t, AllowedTopic("topic*"))
require.False(t, AllowedTopic("*"))
require.False(t, AllowedTopic("my*topic"))
// Invalid topics - special characters
require.False(t, AllowedTopic("my topic")) // Space
require.False(t, AllowedTopic("my.topic")) // Dot
require.False(t, AllowedTopic("my/topic")) // Slash
require.False(t, AllowedTopic("my@topic")) // At sign
require.False(t, AllowedTopic("my+topic")) // Plus
require.False(t, AllowedTopic("topic!")) // Exclamation
require.False(t, AllowedTopic("topic#")) // Hash
require.False(t, AllowedTopic("topic$")) // Dollar
require.False(t, AllowedTopic("topic%")) // Percent
require.False(t, AllowedTopic("topic&")) // Ampersand
require.False(t, AllowedTopic("my\\topic")) // Backslash
// Invalid topics - length
require.False(t, AllowedTopic("")) // Empty
require.False(t, AllowedTopic(strings.Repeat("a", 65))) // Too long
}
func TestAllowedTopicPattern(t *testing.T) {
// Valid patterns - same as AllowedTopic
require.True(t, AllowedTopicPattern("test"))
require.True(t, AllowedTopicPattern("mytopic"))
require.True(t, AllowedTopicPattern("topic123"))
require.True(t, AllowedTopicPattern("my-topic"))
require.True(t, AllowedTopicPattern("my_topic"))
require.True(t, AllowedTopicPattern("a"))
require.True(t, AllowedTopicPattern(strings.Repeat("a", 64))) // Max length
// Valid patterns - with wildcards
require.True(t, AllowedTopicPattern("*"))
require.True(t, AllowedTopicPattern("topic*"))
require.True(t, AllowedTopicPattern("*topic"))
require.True(t, AllowedTopicPattern("my*topic"))
require.True(t, AllowedTopicPattern("***"))
require.True(t, AllowedTopicPattern("test_*"))
require.True(t, AllowedTopicPattern("my-*-topic"))
require.True(t, AllowedTopicPattern(strings.Repeat("*", 64))) // Max length with wildcards
// Invalid patterns - special characters (other than wildcard)
require.False(t, AllowedTopicPattern("my topic")) // Space
require.False(t, AllowedTopicPattern("my.topic")) // Dot
require.False(t, AllowedTopicPattern("my/topic")) // Slash
require.False(t, AllowedTopicPattern("my@topic")) // At sign
require.False(t, AllowedTopicPattern("my+topic")) // Plus
require.False(t, AllowedTopicPattern("topic!")) // Exclamation
require.False(t, AllowedTopicPattern("topic#")) // Hash
require.False(t, AllowedTopicPattern("topic$")) // Dollar
require.False(t, AllowedTopicPattern("topic%")) // Percent
require.False(t, AllowedTopicPattern("topic&")) // Ampersand
require.False(t, AllowedTopicPattern("my\\topic")) // Backslash
// Invalid patterns - length
require.False(t, AllowedTopicPattern("")) // Empty
require.False(t, AllowedTopicPattern(strings.Repeat("a", 65))) // Too long
}
func TestValidPasswordHash(t *testing.T) {
// Valid bcrypt hashes with different versions
require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Nil(t, ValidPasswordHash("$2b$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", 10))
require.Nil(t, ValidPasswordHash("$2y$12$1234567890123456789012u1234567890123456789012345678901", 10))
// Valid hash with minimum cost
require.Nil(t, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 4))
// Invalid - wrong prefix
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$2c$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("$3a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("bcrypt$10$hash", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("nothash", 10))
require.Equal(t, ErrPasswordHashInvalid, ValidPasswordHash("", 10))
// Invalid - malformed hash
require.NotNil(t, ValidPasswordHash("$2a$10$tooshort", 10))
require.NotNil(t, ValidPasswordHash("$2a$10", 10))
require.NotNil(t, ValidPasswordHash("$2a$", 10))
// Invalid - cost too low
require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$04$1234567890123456789012u1234567890123456789012345678901", 10))
require.Equal(t, ErrPasswordHashWeak, ValidPasswordHash("$2a$09$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
// Edge case - cost exactly at minimum
require.Nil(t, ValidPasswordHash("$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy", 10))
}
func TestValidToken(t *testing.T) {
// Valid tokens
require.True(t, ValidToken("tk_1234567890123456789012345678x"))
require.True(t, ValidToken("tk_abcdefghijklmnopqrstuvwxyzabc"))
require.True(t, ValidToken("tk_ABCDEFGHIJKLMNOPQRSTUVWXYZABC"))
require.True(t, ValidToken("tk_012345678901234567890123456ab"))
require.True(t, ValidToken("tk_-----------------------------"))
require.True(t, ValidToken("tk______________________________"))
// Invalid tokens - wrong prefix
require.False(t, ValidToken("tx_1234567890123456789012345678x"))
require.False(t, ValidToken("tk1234567890123456789012345678xy"))
require.False(t, ValidToken("token_1234567890123456789012345"))
// Invalid tokens - wrong length
require.False(t, ValidToken("tk_")) // Too short
require.False(t, ValidToken("tk_123")) // Too short
require.False(t, ValidToken("tk_123456789012345678901234567890")) // Too long (30 chars after prefix)
require.False(t, ValidToken("tk_123456789012345678901234567")) // Too short (28 chars)
// Invalid tokens - invalid characters
require.False(t, ValidToken("tk_123456789012345678901234567!@"))
require.False(t, ValidToken("tk_12345678901234567890123456 8x"))
require.False(t, ValidToken("tk_123456789012345678901234567.x"))
require.False(t, ValidToken("tk_123456789012345678901234567*x"))
// Invalid tokens - no prefix
require.False(t, ValidToken("1234567890123456789012345678901x"))
require.False(t, ValidToken(""))
}
func TestGenerateToken(t *testing.T) {
// Generate multiple tokens
tokens := make(map[string]bool)
for i := 0; i < 100; i++ {
token := GenerateToken()
// Check format
require.True(t, strings.HasPrefix(token, "tk_"), "Token should start with tk_")
require.Equal(t, 32, len(token), "Token should be 32 characters long")
// Check it's valid
require.True(t, ValidToken(token), "Generated token should be valid")
// Check it's lowercase
require.Equal(t, strings.ToLower(token), token, "Token should be lowercase")
// Check uniqueness
require.False(t, tokens[token], "Token should be unique")
tokens[token] = true
}
// Verify we got 100 unique tokens
require.Equal(t, 100, len(tokens))
}
func TestHashPassword(t *testing.T) {
password := "test-password-123"
// Hash the password
hash, err := HashPassword(password)
require.Nil(t, err)
require.NotEmpty(t, hash)
// Check it's a valid bcrypt hash
require.Nil(t, ValidPasswordHash(hash, DefaultUserPasswordBcryptCost))
// Check it starts with correct prefix
require.True(t, strings.HasPrefix(hash, "$2a$"))
// Hash the same password again - should produce different hash
hash2, err := HashPassword(password)
require.Nil(t, err)
require.NotEqual(t, hash, hash2, "Same password should produce different hashes (salt)")
// Empty password should still work
emptyHash, err := HashPassword("")
require.Nil(t, err)
require.NotEmpty(t, emptyHash)
require.Nil(t, ValidPasswordHash(emptyHash, DefaultUserPasswordBcryptCost))
}
func TestHashPassword_WithCost(t *testing.T) {
password := "test-password"
// Test with different costs
hash4, err := hashPassword(password, 4)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash4, "$2a$04$"))
hash10, err := hashPassword(password, 10)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash10, "$2a$10$"))
hash12, err := hashPassword(password, 12)
require.Nil(t, err)
require.True(t, strings.HasPrefix(hash12, "$2a$12$"))
// All should be valid
require.Nil(t, ValidPasswordHash(hash4, 4))
require.Nil(t, ValidPasswordHash(hash10, 10))
require.Nil(t, ValidPasswordHash(hash12, 12))
}
func TestUser_TierID(t *testing.T) {
// User with tier
u := &User{
Tier: &Tier{
ID: "ti_123",
Code: "pro",
},
}
require.Equal(t, "ti_123", u.TierID())
// User without tier
u2 := &User{
Tier: nil,
}
require.Equal(t, "", u2.TierID())
// Nil user
var u3 *User
require.Equal(t, "", u3.TierID())
}
func TestUser_IsAdmin(t *testing.T) {
admin := &User{Role: RoleAdmin}
require.True(t, admin.IsAdmin())
require.False(t, admin.IsUser())
user := &User{Role: RoleUser}
require.False(t, user.IsAdmin())
anonymous := &User{Role: RoleAnonymous}
require.False(t, anonymous.IsAdmin())
// Nil user
var nilUser *User
require.False(t, nilUser.IsAdmin())
}
func TestUser_IsUser(t *testing.T) {
user := &User{Role: RoleUser}
require.True(t, user.IsUser())
require.False(t, user.IsAdmin())
admin := &User{Role: RoleAdmin}
require.False(t, admin.IsUser())
anonymous := &User{Role: RoleAnonymous}
require.False(t, anonymous.IsUser())
// Nil user
var nilUser *User
require.False(t, nilUser.IsUser())
}
func TestPermission_String(t *testing.T) {
require.Equal(t, "read-write", PermissionReadWrite.String())
require.Equal(t, "read-only", PermissionRead.String())
require.Equal(t, "write-only", PermissionWrite.String())
require.Equal(t, "deny-all", PermissionDenyAll.String())
}

View File

@@ -48,5 +48,26 @@
"notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.",
"notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.",
"notifications_none_for_any_title": "לא קיבלת התראות כלל.",
"notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין."
"notifications_no_subscriptions_title": "נראה שלא נרשמת למינויים עדיין.",
"action_bar_toggle_mute": "השתקת/הפעלת התראות",
"action_bar_toggle_action_menu": "פתיחת/סגירת תפריט הפעולות",
"action_bar_profile_title": "פרופיל",
"action_bar_profile_settings": "הגדרות",
"action_bar_profile_logout": "יציאה",
"action_bar_sign_in": "כניסה",
"action_bar_sign_up": "הרשמה",
"message_bar_type_message": "כאן ניתן להקליד הודעה",
"message_bar_error_publishing": "שגיאה בפרסום ההתראה",
"message_bar_show_dialog": "הצגת חלונית פרסום",
"message_bar_publish": "פרסום הודעה",
"nav_topics_title": "נושאים שנרשמת אליהם",
"nav_button_all_notifications": "כל ההתראות",
"nav_button_account": "חשבון",
"nav_button_settings": "הגדרות",
"nav_button_documentation": "תיעוד",
"nav_button_publish_message": "פרסום התראה",
"nav_button_subscribe": "הרשמה לנושא",
"nav_button_muted": "התראות הושתקו",
"nav_button_connecting": "מתחבר",
"nav_upgrade_banner_label": "שדרוג ל־ntfy Pro"
}

View File

@@ -406,5 +406,7 @@
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
"web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie",
"alert_notification_permission_required_title": "Oznámenia sú vypnuté",
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS"
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Prideleného používateľa nemožno upraviť ani odstrániť",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Pridelený token nemožno upraviť ani odstrániť"
}