Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
941c43c10b | ||
|
|
af76aa011d | ||
|
|
b937b44f2d | ||
|
|
e618cf1a39 | ||
|
|
e9cf2b5523 | ||
|
|
c49a8179cf | ||
|
|
0d375d3a08 | ||
|
|
8b12bdeb3a | ||
|
|
4e22e7f4c8 | ||
|
|
cf3ae187ce | ||
|
|
d19b825192 | ||
|
|
2e499389fc | ||
|
|
93cd7f99f8 | ||
|
|
28e85df36e | ||
|
|
2bc7b5217b | ||
|
|
046c0e8c79 | ||
|
|
652b2097ad | ||
|
|
ceda5ec3d8 | ||
|
|
3d72845c81 | ||
|
|
0edad84d86 | ||
|
|
ddf728acd1 | ||
|
|
b1d3671dbb | ||
|
|
3e6b46ec0c | ||
|
|
b16d381626 | ||
|
|
3bd1a1ea03 | ||
|
|
7adb37b94b | ||
|
|
bc08819525 |
13
Makefile
13
Makefile
@@ -1,4 +1,5 @@
|
|||||||
MAKEFLAGS := --jobs=1
|
MAKEFLAGS := --jobs=1
|
||||||
|
NPM := npm
|
||||||
PYTHON := python3
|
PYTHON := python3
|
||||||
PIP := pip3
|
PIP := pip3
|
||||||
VERSION := $(shell git describe --tag)
|
VERSION := $(shell git describe --tag)
|
||||||
@@ -137,7 +138,7 @@ web: web-deps web-build
|
|||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
cd web \
|
cd web \
|
||||||
&& npm run build \
|
&& $(NPM) run build \
|
||||||
&& mv build/index.html build/app.html \
|
&& mv build/index.html build/app.html \
|
||||||
&& rm -rf ../server/site \
|
&& rm -rf ../server/site \
|
||||||
&& mv build ../server/site \
|
&& mv build ../server/site \
|
||||||
@@ -145,20 +146,20 @@ web-build:
|
|||||||
../server/site/config.js
|
../server/site/config.js
|
||||||
|
|
||||||
web-deps:
|
web-deps:
|
||||||
cd web && npm install
|
cd web && $(NPM) install
|
||||||
# If this fails for .svg files, optimize them with svgo
|
# If this fails for .svg files, optimize them with svgo
|
||||||
|
|
||||||
web-deps-update:
|
web-deps-update:
|
||||||
cd web && npm update
|
cd web && $(NPM) update
|
||||||
|
|
||||||
web-fmt:
|
web-fmt:
|
||||||
cd web && npm run format
|
cd web && $(NPM) run format
|
||||||
|
|
||||||
web-fmt-check:
|
web-fmt-check:
|
||||||
cd web && npm run format:check
|
cd web && $(NPM) run format:check
|
||||||
|
|
||||||
web-lint:
|
web-lint:
|
||||||
cd web && npm run lint
|
cd web && $(NPM) run lint
|
||||||
|
|
||||||
# Main server/client build
|
# Main server/client build
|
||||||
|
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ Here's an example:
|
|||||||
```
|
```
|
||||||
# Comma-separated list
|
# Comma-separated list
|
||||||
NTFY_AUTH_FILE='/var/lib/ntfy/user.db'
|
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'
|
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`
|
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
|
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.
|
that all other users and anonymous access are denied by default.
|
||||||
|
|
||||||
=== "Config via /etc/ntfy/server.yml"
|
=== "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"
|
- "phil:$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C:admin"
|
||||||
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
|
- "backup-script:$2a$10$/ehiQt.w7lhTmHXq.RNsOOkIwiPPeWFIzWYO3DRxNixnWKLX8.uj.:user"
|
||||||
auth-access:
|
auth-access:
|
||||||
- "backup-service:backups:rw"
|
- "backup-script:backups:rw"
|
||||||
auth-tokens:
|
auth-tokens:
|
||||||
- "phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token"
|
- "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_FILE='/var/lib/ntfy/user.db'
|
||||||
NTFY_AUTH_DEFAULT_ACCESS='deny-all'
|
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_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'
|
NTFY_AUTH_TOKENS='phil:tk_3gd7d2yftt4b8ixyfe9mnmro88o76:My personal token'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
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:
|
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)
|
# To build an unsigned .apk (app/build/outputs/apk/fdroid/*.apk)
|
||||||
./gradlew assembleFdroidRelease
|
./gradlew assembleFdroidRelease
|
||||||
|
|
||||||
@@ -351,6 +347,8 @@ sed -i -e '/google-services/d' app/build.gradle
|
|||||||
./gradlew bundleFdroidRelease
|
./gradlew bundleFdroidRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The F-Droid flavor automatically excludes Google Services dependencies.
|
||||||
|
|
||||||
### Build Play flavor (FCM)
|
### Build Play flavor (FCM)
|
||||||
!!! info
|
!!! info
|
||||||
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
I do build the ntfy Android app using IntelliJ IDEA (Android Studio), so I don't know if these Gradle commands will
|
||||||
|
|||||||
@@ -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 token=$(< ~/.ntfy_token) # Securely read the token
|
||||||
local status_icon="$([ $exit_status -eq 0 ] && echo magic_wand || echo warning)"
|
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$//')
|
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" \
|
curl -s -X POST "https://n.example.dev/alerts" \
|
||||||
-H "Authorization: Bearer $token" \
|
-H "Authorization: Bearer $token" \
|
||||||
@@ -692,4 +694,4 @@ To test failure notifications:
|
|||||||
false; alert # Always fails (exit 1)
|
false; alert # Always fails (exit 1)
|
||||||
ls --invalid; alert # Invalid option
|
ls --invalid; alert # Invalid option
|
||||||
cat nonexistent_file; alert # File not found
|
cat nonexistent_file; alert # File not found
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
- [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)
|
- [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)
|
- [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
|
## Blog + forum posts
|
||||||
|
|
||||||
@@ -304,7 +306,7 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
|||||||
| URL | Country |
|
| URL | Country |
|
||||||
|---------------------------------------------------|--------------------|
|
|---------------------------------------------------|--------------------|
|
||||||
| [ntfy.sh](https://ntfy.sh/) (*Official*) | 🇺🇸 United States |
|
| [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.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
|
||||||
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
||||||
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
||||||
|
|||||||
@@ -7,11 +7,32 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
| Component | Version | Release date |
|
| Component | Version | Release date |
|
||||||
|------------------|---------|--------------|
|
|------------------|---------|--------------|
|
||||||
| ntfy server | v2.17.0 | Feb 8, 2026 |
|
| 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 |
|
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||||
|
|
||||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
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
|
## ntfy server v2.17.0
|
||||||
Released February 8, 2026
|
Released February 8, 2026
|
||||||
|
|
||||||
@@ -1698,19 +1719,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
## Not released yet
|
## Not released yet
|
||||||
|
|
||||||
### ntfy Android v1.23.x (UNRELEASED)
|
### ntfy server v2.18.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)
|
|
||||||
|
|
||||||
**Bug fixes + maintenance:**
|
**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)
|
* 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)
|
||||||
* 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)
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ var (
|
|||||||
var (
|
var (
|
||||||
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
|
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
|
||||||
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
|
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
|
||||||
|
htmlLineBreakRegex = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -327,6 +328,9 @@ func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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.
|
stripped := bluemonday.
|
||||||
StrictPolicy().
|
StrictPolicy().
|
||||||
AddSpaceWhenStrippingTag(true).
|
AddSpaceWhenStrippingTag(true).
|
||||||
|
|||||||
@@ -694,7 +694,8 @@ home automation setup
|
|||||||
Now the light is on
|
Now the light is on
|
||||||
|
|
||||||
If you don't want to receive this message anymore, stop the push
|
If you don'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: "System > Push Service".
|
Here you can see the active push services: "System > Push Service".
|
||||||
|
|
||||||
This mail has ben sent by your FRITZ!Box automatically.`
|
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) {
|
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "/synology", r.URL.Path)
|
require.Equal(t, "/synology", r.URL.Path)
|
||||||
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
|
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.\n" +
|
||||||
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`
|
"For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\n" +
|
||||||
require.Equal(t, expected, actual)
|
"(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.SMTPServerDomain = "mydomain.me"
|
||||||
conf.SMTPServerAddrPrefix = ""
|
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")
|
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) {
|
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `EHLO example.com
|
||||||
MAIL FROM: phil@example.com
|
MAIL FROM: phil@example.com
|
||||||
|
|||||||
415
user/manager.go
415
user/manager.go
@@ -6,17 +6,17 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mattn/go-sqlite3"
|
"github.com/mattn/go-sqlite3"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"heckel.io/ntfy/v2/payments"
|
"heckel.io/ntfy/v2/payments"
|
||||||
"heckel.io/ntfy/v2/util"
|
"heckel.io/ntfy/v2/util"
|
||||||
"net/netip"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// Manager is an implementation of Manager. It stores users and access control list
|
||||||
// in a SQLite database.
|
// in a SQLite database.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
@@ -1840,7 +1617,7 @@ func (a *Manager) maybeProvisionUsers(tx *sql.Tx, provisionUsernames []string, e
|
|||||||
return nil
|
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
|
// 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).
|
// 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
|
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 {
|
func runStartupQueries(db *sql.DB, startupQueries string) error {
|
||||||
if _, err := db.Exec(startupQueries); err != nil {
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1983,161 +1740,3 @@ func setupNewDB(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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
342
user/migrations.go
Normal 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()
|
||||||
|
}
|
||||||
72
user/util.go
72
user/util.go
@@ -1,10 +1,12 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"database/sql"
|
||||||
"heckel.io/ntfy/v2/util"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"heckel.io/ntfy/v2/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -77,3 +79,69 @@ func hashPassword(password string, cost int) (string, error) {
|
|||||||
}
|
}
|
||||||
return string(hash), nil
|
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
281
user/util_test.go
Normal 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())
|
||||||
|
}
|
||||||
@@ -48,5 +48,26 @@
|
|||||||
"notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.",
|
"notifications_none_for_topic_title": "לא קיבלת התראות בנושא הזה עדיין.",
|
||||||
"notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.",
|
"notifications_none_for_topic_description": "כדי לשלוח התראות לנושא הזה, צריך לשלוח PUT או POST לכתובת הנושא הזה.",
|
||||||
"notifications_none_for_any_title": "לא קיבלת התראות כלל.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,5 +406,7 @@
|
|||||||
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
|
"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",
|
"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_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ť"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user