Compare commits
18 Commits
windows-se
...
cancel-sch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014b7355c5 | ||
|
|
602f201bae | ||
|
|
2739d8a325 | ||
|
|
b8e01fde33 | ||
|
|
9ecf21c65a | ||
|
|
ac9cfbfaf4 | ||
|
|
c23d201186 | ||
|
|
86157fc7f6 | ||
|
|
279c164bf5 | ||
|
|
743b00e59c | ||
|
|
eddf654b96 | ||
|
|
886be722bc | ||
|
|
6886ca24b1 | ||
|
|
856f150958 | ||
|
|
5a1aa68ead | ||
|
|
cc9f9c0d24 | ||
|
|
8deb2df88d | ||
|
|
603273ab9d |
11
cmd/app.go
11
cmd/app.go
@@ -3,11 +3,12 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/v2/log"
|
"heckel.io/ntfy/v2/log"
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -15,6 +16,12 @@ const (
|
|||||||
categoryServer = "Server commands"
|
categoryServer = "Server commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build metadata keys for app.Metadata
|
||||||
|
const (
|
||||||
|
MetadataKeyCommit = "commit"
|
||||||
|
MetadataKeyDate = "date"
|
||||||
|
)
|
||||||
|
|
||||||
var commands = make([]*cli.Command, 0)
|
var commands = make([]*cli.Command, 0)
|
||||||
|
|
||||||
var flagsDefault = []cli.Flag{
|
var flagsDefault = []cli.Flag{
|
||||||
|
|||||||
19
cmd/serve.go
19
cmd/serve.go
@@ -501,7 +501,9 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.WebPushStartupQueries = webPushStartupQueries
|
conf.WebPushStartupQueries = webPushStartupQueries
|
||||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||||
conf.Version = c.App.Version
|
conf.BuildVersion = c.App.Version
|
||||||
|
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
|
||||||
|
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
|
||||||
|
|
||||||
// Check if we should run as a Windows service
|
// Check if we should run as a Windows service
|
||||||
if ranAsService, err := maybeRunAsService(conf); err != nil {
|
if ranAsService, err := maybeRunAsService(conf); err != nil {
|
||||||
@@ -655,3 +657,18 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
|
|||||||
}
|
}
|
||||||
return tokens, nil
|
return tokens, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeFromMetadata(m map[string]any, key string) string {
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
v, exists := m[key]
|
||||||
|
if !exists {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
2641
docs/publish.md
2641
docs/publish.md
File diff suppressed because one or more lines are too long
@@ -1605,8 +1605,15 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
|||||||
|
|
||||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||||
|
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||||
|
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||||
|
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||||
|
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
|
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||||
|
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||||
|
thanks to [@wtf911](https://github.com/wtf911))
|
||||||
|
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||||
|
|
||||||
### ntfy Android app v1.22.x (UNRELEASED)
|
### ntfy Android app v1.22.x (UNRELEASED)
|
||||||
|
|
||||||
|
|||||||
19
main.go
19
main.go
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
"heckel.io/ntfy/v2/cmd"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/v2/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// These variables are set during build time using -ldflags
|
||||||
var (
|
var (
|
||||||
version = "dev"
|
version = "dev"
|
||||||
commit = "unknown"
|
commit = "unknown"
|
||||||
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
|||||||
|
|
||||||
ntfy %s (%s), runtime %s, built at %s
|
ntfy %s (%s), runtime %s, built at %s
|
||||||
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||||
`, version, commit[:7], runtime.Version(), date)
|
`, version, maybeShortCommit(commit), runtime.Version(), date)
|
||||||
|
|
||||||
app := cmd.New()
|
app := cmd.New()
|
||||||
app.Version = version
|
app.Version = version
|
||||||
|
app.Metadata = map[string]any{
|
||||||
|
cmd.MetadataKeyDate: date,
|
||||||
|
cmd.MetadataKeyCommit: commit,
|
||||||
|
}
|
||||||
|
|
||||||
if err := app.Run(os.Args); err != nil {
|
if err := app.Run(os.Args); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err.Error())
|
fmt.Fprintln(os.Stderr, err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeShortCommit(commit string) string {
|
||||||
|
if len(commit) > 7 {
|
||||||
|
return commit[:7]
|
||||||
|
}
|
||||||
|
return commit
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -179,7 +183,9 @@ type Config struct {
|
|||||||
WebPushStartupQueries string
|
WebPushStartupQueries string
|
||||||
WebPushExpiryDuration time.Duration
|
WebPushExpiryDuration time.Duration
|
||||||
WebPushExpiryWarningDuration time.Duration
|
WebPushExpiryWarningDuration time.Duration
|
||||||
Version string // injected by App
|
BuildVersion string // Injected by App
|
||||||
|
BuildDate string // Injected by App
|
||||||
|
BuildCommit string // Injected by App
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
@@ -266,12 +272,32 @@ func NewConfig() *Config {
|
|||||||
EnableReservations: false,
|
EnableReservations: false,
|
||||||
RequireLogin: false,
|
RequireLogin: false,
|
||||||
AccessControlAllowOrigin: "*",
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
|
||||||
WebPushPrivateKey: "",
|
WebPushPrivateKey: "",
|
||||||
WebPushPublicKey: "",
|
WebPushPublicKey: "",
|
||||||
WebPushFile: "",
|
WebPushFile: "",
|
||||||
WebPushEmailAddress: "",
|
WebPushEmailAddress: "",
|
||||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||||
|
BuildVersion: "",
|
||||||
|
BuildDate: "",
|
||||||
|
BuildCommit: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hash computes an SHA-256 hash of the configuration. This is used to detect
|
||||||
|
// configuration changes for the web app version check feature. It uses reflection
|
||||||
|
// to include all JSON-serializable fields automatically.
|
||||||
|
func (c *Config) Hash() string {
|
||||||
|
v := reflect.ValueOf(*c)
|
||||||
|
t := v.Type()
|
||||||
|
var result string
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Field(i)
|
||||||
|
fieldName := t.Field(i).Name
|
||||||
|
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
|
||||||
|
if b, err := json.Marshal(field.Interface()); err == nil {
|
||||||
|
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ const (
|
|||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
|
selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||||
|
deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
selectMessagesByIDQuery = `
|
selectMessagesByIDQuery = `
|
||||||
@@ -607,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
|
||||||
|
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
|
||||||
|
func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
tx, err := c.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
// First, get the message IDs of scheduled messages to be deleted
|
||||||
|
rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows.Close() // Close rows before executing delete in same transaction
|
||||||
|
// Then delete the messages
|
||||||
|
if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -703,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||||
|
testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||||
|
testDeleteScheduledBySequenceID(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
|
||||||
|
// Create a scheduled (unpublished) message
|
||||||
|
scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
|
||||||
|
scheduledMsg.ID = "scheduled1"
|
||||||
|
scheduledMsg.SequenceID = "seq123"
|
||||||
|
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
|
||||||
|
require.Nil(t, c.AddMessage(scheduledMsg))
|
||||||
|
|
||||||
|
// Create a published message with different sequence ID
|
||||||
|
publishedMsg := newDefaultMessage("mytopic", "published message")
|
||||||
|
publishedMsg.ID = "published1"
|
||||||
|
publishedMsg.SequenceID = "seq456"
|
||||||
|
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
|
||||||
|
require.Nil(t, c.AddMessage(publishedMsg))
|
||||||
|
|
||||||
|
// Create a scheduled message in a different topic
|
||||||
|
otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
|
||||||
|
otherTopicMsg.ID = "other1"
|
||||||
|
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
|
||||||
|
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(otherTopicMsg))
|
||||||
|
|
||||||
|
// Verify all messages exist (including scheduled)
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
|
||||||
|
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
|
||||||
|
// Delete scheduled message by sequence ID and verify returned IDs
|
||||||
|
deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(deletedIDs))
|
||||||
|
require.Equal(t, "scheduled1", deletedIDs[0])
|
||||||
|
|
||||||
|
// Verify scheduled message is deleted
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "published message", messages[0].Message)
|
||||||
|
|
||||||
|
// Verify other topic's message still exists (topic-scoped deletion)
|
||||||
|
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "other scheduled", messages[0].Message)
|
||||||
|
|
||||||
|
// Deleting non-existent sequence ID should return empty list
|
||||||
|
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, deletedIDs)
|
||||||
|
|
||||||
|
// Deleting published message should not affect it (only deletes unpublished)
|
||||||
|
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Empty(t, deletedIDs)
|
||||||
|
|
||||||
|
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "published message", messages[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ var (
|
|||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
metricsPath = "/metrics"
|
metricsPath = "/metrics"
|
||||||
apiHealthPath = "/v1/health"
|
apiHealthPath = "/v1/health"
|
||||||
|
apiConfigPath = "/v1/config"
|
||||||
apiStatsPath = "/v1/stats"
|
apiStatsPath = "/v1/stats"
|
||||||
apiWebPushPath = "/v1/webpush"
|
apiWebPushPath = "/v1/webpush"
|
||||||
apiTiersPath = "/v1/tiers"
|
apiTiersPath = "/v1/tiers"
|
||||||
@@ -277,9 +278,9 @@ func (s *Server) Run() error {
|
|||||||
if s.config.ProfileListenHTTP != "" {
|
if s.config.ProfileListenHTTP != "" {
|
||||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||||
}
|
}
|
||||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
|
||||||
if log.IsFile() {
|
if log.IsFile() {
|
||||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
|
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
|
||||||
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
|
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -460,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||||
return s.handleHealth(w, r, v)
|
return s.handleHealth(w, r, v)
|
||||||
|
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||||
|
return s.handleConfig(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||||
@@ -600,8 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
|
|||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
return s.writeJSON(w, s.configResponse())
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
response := &apiConfigResponse{
|
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/javascript")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) configResponse() *apiConfigResponse {
|
||||||
|
return &apiConfigResponse{
|
||||||
BaseURL: "", // Will translate to window.location.origin
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
AppRoot: s.config.WebRoot,
|
AppRoot: s.config.WebRoot,
|
||||||
EnableLogin: s.config.EnableLogin,
|
EnableLogin: s.config.EnableLogin,
|
||||||
@@ -615,15 +634,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
BillingContact: s.config.BillingContact,
|
BillingContact: s.config.BillingContact,
|
||||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||||
DisallowedTopics: s.config.DisallowedTopics,
|
DisallowedTopics: s.config.DisallowedTopics,
|
||||||
|
ConfigHash: s.config.Hash(),
|
||||||
}
|
}
|
||||||
b, err := json.MarshalIndent(response, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/javascript")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||||
@@ -851,6 +863,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||||
}
|
}
|
||||||
if cache {
|
if cache {
|
||||||
|
// Delete any existing scheduled message with the same sequence ID
|
||||||
|
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Delete attachment files for deleted scheduled messages
|
||||||
|
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||||
|
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||||
|
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -946,6 +969,19 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
|
|||||||
if s.config.WebPushPublicKey != "" {
|
if s.config.WebPushPublicKey != "" {
|
||||||
go s.publishToWebPushEndpoints(v, m)
|
go s.publishToWebPushEndpoints(v, m)
|
||||||
}
|
}
|
||||||
|
if event == messageDeleteEvent {
|
||||||
|
// Delete any existing scheduled message with the same sequence ID
|
||||||
|
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Delete attachment files for deleted scheduled messages
|
||||||
|
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||||
|
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||||
|
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Add to message cache
|
// Add to message cache
|
||||||
if err := s.messageCache.AddMessage(m); err != nil {
|
if err := s.messageCache.AddMessage(m); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -991,7 +1027,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
|||||||
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||||
req.Header.Set("X-Poll-ID", m.ID)
|
req.Header.Set("X-Poll-ID", m.ID)
|
||||||
if s.config.UpstreamAccessToken != "" {
|
if s.config.UpstreamAccessToken != "" {
|
||||||
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
|
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
|
||||||
|
|||||||
@@ -3495,6 +3495,162 @@ func TestServer_ClearMessage_WithFirebase(t *testing.T) {
|
|||||||
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
|
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message (future delivery)
|
||||||
|
response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg1 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "sched-seq", msg1.SequenceID)
|
||||||
|
require.Equal(t, "original scheduled message", msg1.Message)
|
||||||
|
|
||||||
|
// Verify scheduled message exists
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "original scheduled message", messages[0].Message)
|
||||||
|
|
||||||
|
// Update the scheduled message (same sequence ID, new content)
|
||||||
|
response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg2 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "sched-seq", msg2.SequenceID)
|
||||||
|
require.Equal(t, "updated scheduled message", msg2.Message)
|
||||||
|
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||||
|
|
||||||
|
// Verify only the updated message exists (old scheduled was deleted)
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "updated scheduled message", messages[0].Message)
|
||||||
|
require.Equal(t, msg2.ID, messages[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message (future delivery)
|
||||||
|
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||||
|
|
||||||
|
// Verify scheduled message exists
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||||
|
|
||||||
|
// Delete the scheduled message
|
||||||
|
response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
deleteMsg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||||
|
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||||
|
|
||||||
|
// Verify scheduled message was deleted, only delete event remains
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "message_delete", messages[0].Event)
|
||||||
|
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish scheduled messages with same sequence ID in different topics
|
||||||
|
response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// Update scheduled message in topic1 only
|
||||||
|
response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// Verify topic1 has only the updated message
|
||||||
|
response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages := toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "topic1 updated", messages[0].Message)
|
||||||
|
|
||||||
|
// Verify topic2 still has its original scheduled message (not affected)
|
||||||
|
response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "topic2 scheduled", messages[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message with an attachment
|
||||||
|
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg1 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "attach-seq", msg1.SequenceID)
|
||||||
|
require.NotNil(t, msg1.Attachment)
|
||||||
|
|
||||||
|
// Verify attachment file exists
|
||||||
|
attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)
|
||||||
|
require.FileExists(t, attachmentFile1)
|
||||||
|
|
||||||
|
// Update the scheduled message with a new attachment
|
||||||
|
newContent := util.RandomString(5000)
|
||||||
|
response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg2 := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "attach-seq", msg2.SequenceID)
|
||||||
|
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||||
|
|
||||||
|
// Verify old attachment file was deleted
|
||||||
|
require.NoFileExists(t, attachmentFile1)
|
||||||
|
|
||||||
|
// Verify new attachment file exists
|
||||||
|
attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)
|
||||||
|
require.FileExists(t, attachmentFile2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
// Publish a scheduled message with an attachment
|
||||||
|
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "delete-attach-seq", msg.SequenceID)
|
||||||
|
require.NotNil(t, msg.Attachment)
|
||||||
|
|
||||||
|
// Verify attachment file exists
|
||||||
|
attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||||
|
require.FileExists(t, attachmentFile)
|
||||||
|
|
||||||
|
// Delete the scheduled message
|
||||||
|
response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
deleteMsg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||||
|
|
||||||
|
// Verify attachment file was deleted
|
||||||
|
require.NoFileExists(t, attachmentFile)
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
@@ -149,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
@@ -175,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ type apiConfigResponse struct {
|
|||||||
BillingContact string `json:"billing_contact"`
|
BillingContact string `json:"billing_contact"`
|
||||||
WebPushPublicKey string `json:"web_push_public_key"`
|
WebPushPublicKey string `json:"web_push_public_key"`
|
||||||
DisallowedTopics []string `json:"disallowed_topics"`
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
|
ConfigHash string `json:"config_hash"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountBillingPrices struct {
|
type apiAccountBillingPrices struct {
|
||||||
|
|||||||
@@ -19,4 +19,5 @@ var config = {
|
|||||||
billing_contact: "",
|
billing_contact: "",
|
||||||
web_push_public_key: "",
|
web_push_public_key: "",
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
||||||
|
config_hash: "dev", // Placeholder for development; actual value is generated server-side
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
"common_add": "Add",
|
"common_add": "Add",
|
||||||
"common_back": "Back",
|
"common_back": "Back",
|
||||||
"common_copy_to_clipboard": "Copy to clipboard",
|
"common_copy_to_clipboard": "Copy to clipboard",
|
||||||
|
"common_refresh": "Refresh",
|
||||||
|
"version_update_available_title": "New version available",
|
||||||
|
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ class Pruner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopWorker() {
|
stopWorker() {
|
||||||
|
if (this.timer) {
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
console.log("[Pruner] Stopped worker");
|
||||||
}
|
}
|
||||||
|
|
||||||
async prune() {
|
async prune() {
|
||||||
|
|||||||
72
web/src/app/VersionChecker.js
Normal file
72
web/src/app/VersionChecker.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* VersionChecker polls the /v1/config endpoint to detect new server versions
|
||||||
|
* or configuration changes, prompting users to refresh the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const intervalMillis = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
class VersionChecker {
|
||||||
|
constructor() {
|
||||||
|
this.initialConfigHash = null;
|
||||||
|
this.listener = null;
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the version checker worker. It stores the initial config hash
|
||||||
|
* from the config.js and polls the server every 5 minutes.
|
||||||
|
*/
|
||||||
|
startWorker() {
|
||||||
|
// Store initial config hash from the config loaded at page load
|
||||||
|
this.initialConfigHash = window.config?.config_hash || "";
|
||||||
|
console.log("[VersionChecker] Starting version checker");
|
||||||
|
this.timer = setInterval(() => this.checkVersion(), intervalMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWorker() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
console.log("[VersionChecker] Stopped version checker");
|
||||||
|
}
|
||||||
|
|
||||||
|
registerListener(listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetListener() {
|
||||||
|
this.listener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkVersion() {
|
||||||
|
if (!this.initialConfigHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log("[VersionChecker] Failed to fetch config:", response.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const currentHash = data.config_hash;
|
||||||
|
|
||||||
|
if (currentHash && currentHash !== this.initialConfigHash) {
|
||||||
|
console.log("[VersionChecker] Version or config changed, showing banner");
|
||||||
|
if (this.listener) {
|
||||||
|
this.listener();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("[VersionChecker] No version change detected");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[VersionChecker] Error checking config:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionChecker = new VersionChecker();
|
||||||
|
export default versionChecker;
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
Drawer,
|
|
||||||
ListItemButton,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Toolbar,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
Alert,
|
Alert,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
ListSubheader,
|
ListSubheader,
|
||||||
Portal,
|
Portal,
|
||||||
|
Toolbar,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Button,
|
|
||||||
useTheme,
|
useTheme,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
|||||||
import { AccountContext } from "./App";
|
import { AccountContext } from "./App";
|
||||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||||
import { useNotificationPermissionListener } from "./hooks";
|
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
|
||||||
@@ -91,6 +91,13 @@ const NavList = (props) => {
|
|||||||
const { account } = useContext(AccountContext);
|
const { account } = useContext(AccountContext);
|
||||||
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
||||||
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
||||||
|
const [versionChanged, setVersionChanged] = useState(false);
|
||||||
|
|
||||||
|
const handleVersionChange = () => {
|
||||||
|
setVersionChanged(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useVersionChangeListener(handleVersionChange);
|
||||||
|
|
||||||
const handleSubscribeReset = () => {
|
const handleSubscribeReset = () => {
|
||||||
setSubscribeDialogOpen(false);
|
setSubscribeDialogOpen(false);
|
||||||
@@ -119,6 +126,7 @@ const NavList = (props) => {
|
|||||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||||
|
|
||||||
const alertVisible =
|
const alertVisible =
|
||||||
|
versionChanged ||
|
||||||
showNotificationPermissionRequired ||
|
showNotificationPermissionRequired ||
|
||||||
showNotificationPermissionDenied ||
|
showNotificationPermissionDenied ||
|
||||||
showNotificationIOSInstallRequired ||
|
showNotificationIOSInstallRequired ||
|
||||||
@@ -129,6 +137,7 @@ const NavList = (props) => {
|
|||||||
<>
|
<>
|
||||||
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
||||||
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
|
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
|
||||||
|
{versionChanged && <VersionUpdateBanner />}
|
||||||
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
|
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
|
||||||
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
||||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
||||||
@@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VersionUpdateBanner = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Alert severity="info" sx={{ paddingTop: 2 }}>
|
||||||
|
<AlertTitle>{t("version_update_available_title")}</AlertTitle>
|
||||||
|
<Typography gutterBottom>{t("version_update_available_description")}</Typography>
|
||||||
|
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={handleRefresh}>
|
||||||
|
{t("common_refresh")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Navigation;
|
export default Navigation;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import poller from "../app/Poller";
|
|||||||
import pruner from "../app/Pruner";
|
import pruner from "../app/Pruner";
|
||||||
import session from "../app/Session";
|
import session from "../app/Session";
|
||||||
import accountApi from "../app/AccountApi";
|
import accountApi from "../app/AccountApi";
|
||||||
|
import versionChecker from "../app/VersionChecker";
|
||||||
import { UnauthorizedError } from "../app/errors";
|
import { UnauthorizedError } from "../app/errors";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import prefs from "../app/Prefs";
|
import prefs from "../app/Prefs";
|
||||||
@@ -292,12 +293,14 @@ const startWorkers = () => {
|
|||||||
poller.startWorker();
|
poller.startWorker();
|
||||||
pruner.startWorker();
|
pruner.startWorker();
|
||||||
accountApi.startWorker();
|
accountApi.startWorker();
|
||||||
|
versionChecker.startWorker();
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopWorkers = () => {
|
const stopWorkers = () => {
|
||||||
poller.stopWorker();
|
poller.stopWorker();
|
||||||
pruner.stopWorker();
|
pruner.stopWorker();
|
||||||
accountApi.stopWorker();
|
accountApi.stopWorker();
|
||||||
|
versionChecker.stopWorker();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useBackgroundProcesses = () => {
|
export const useBackgroundProcesses = () => {
|
||||||
@@ -323,3 +326,15 @@ export const useAccountListener = (setAccount) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect version/config changes and call the provided callback when a change is detected.
|
||||||
|
*/
|
||||||
|
export const useVersionChangeListener = (onVersionChange) => {
|
||||||
|
useEffect(() => {
|
||||||
|
versionChecker.registerListener(onVersionChange);
|
||||||
|
return () => {
|
||||||
|
versionChecker.resetListener();
|
||||||
|
};
|
||||||
|
}, [onVersionChange]);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user