mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-03-18 21:30:44 +01:00
Compare commits
1 Commits
v2.19.0
...
dockers-v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7093ac569e |
@@ -54,9 +54,8 @@ builds:
|
||||
ldflags:
|
||||
- "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [ windows ]
|
||||
goarch: [amd64 ]
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
@@ -67,8 +66,8 @@ builds:
|
||||
goarch: [ amd64, arm64 ] # will be combined to "universal binary" (see below)
|
||||
nfpms:
|
||||
- package_name: ntfy
|
||||
homepage: https://heckel.io/ntfy
|
||||
maintainer: Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
homepage: https://ntfy.sh
|
||||
maintainer: Philipp C. Heckel <phil@mail.ntfy.sh>
|
||||
description: Simple pub-sub notification service
|
||||
license: Apache 2.0
|
||||
formats:
|
||||
@@ -148,59 +147,22 @@ changelog:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
dockers:
|
||||
- image_templates:
|
||||
- &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile
|
||||
goarch: amd64
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- image_templates:
|
||||
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile-arm
|
||||
goarch: arm64
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64/v8"
|
||||
- image_templates:
|
||||
- &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile-arm
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
- image_templates:
|
||||
- &armv6_image "binwiederhier/ntfy:{{ .Tag }}-armv6"
|
||||
use: buildx
|
||||
dockerfile: Dockerfile-arm
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v6"
|
||||
docker_manifests:
|
||||
- name_template: "binwiederhier/ntfy:latest"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:v{{ .Major }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- name_template: "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}"
|
||||
image_templates:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
dockers_v2:
|
||||
- dockerfile: Dockerfile
|
||||
ids:
|
||||
- ntfy_linux_amd64
|
||||
- ntfy_linux_armv6
|
||||
- ntfy_linux_armv7
|
||||
- ntfy_linux_arm64
|
||||
images:
|
||||
- "binwiederhier/ntfy"
|
||||
tags:
|
||||
- "latest"
|
||||
- "{{ .Tag }}"
|
||||
- "v{{ .Major }}"
|
||||
- "v{{ .Major }}.{{ .Minor }}"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/v7
|
||||
- linux/arm/v6
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,6 +1,9 @@
|
||||
FROM --platform=$BUILDPLATFORM alpine AS tzdata
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
FROM alpine
|
||||
|
||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||
LABEL org.opencontainers.image.authors="Philipp C. Heckel <phil@mail.ntfy.sh>"
|
||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
||||
@@ -9,8 +12,13 @@ LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||
LABEL org.opencontainers.image.title="ntfy"
|
||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||
|
||||
RUN apk add --no-cache tzdata
|
||||
COPY ntfy /usr/bin
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Install tzdata in a separate stage using the build platform to avoid
|
||||
# needing QEMU to run "apk add" for non-native target architectures
|
||||
COPY --from=tzdata /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
|
||||
COPY $TARGETPLATFORM/ntfy /usr/bin/ntfy
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM alpine
|
||||
|
||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
||||
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||
LABEL org.opencontainers.image.title="ntfy"
|
||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||
|
||||
# Alpine does not support adding "tzdata" on ARM anymore, see
|
||||
# https://github.com/binwiederhier/ntfy/issues/894
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
@@ -40,7 +40,6 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "database-url", Aliases: []string{"database_url"}, EnvVars: []string{"NTFY_DATABASE_URL"}, Usage: "PostgreSQL connection string for database-backed stores (e.g. postgres://user:pass@host:5432/ntfy)"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "database-replica-urls", Aliases: []string{"database_replica_urls"}, EnvVars: []string{"NTFY_DATABASE_REPLICA_URLS"}, Usage: "PostgreSQL read replica connection strings for offloading read queries"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||
@@ -146,7 +145,6 @@ func execServe(c *cli.Context) error {
|
||||
certFile := c.String("cert-file")
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
databaseURL := c.String("database-url")
|
||||
databaseReplicaURLs := c.StringSlice("database-replica-urls")
|
||||
webPushPrivateKey := c.String("web-push-private-key")
|
||||
webPushPublicKey := c.String("web-push-public-key")
|
||||
webPushFile := c.String("web-push-file")
|
||||
@@ -284,12 +282,8 @@ func execServe(c *cli.Context) error {
|
||||
}
|
||||
|
||||
// Check values
|
||||
if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") {
|
||||
return errors.New("if database-url is set, it must start with postgres://")
|
||||
} else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") {
|
||||
if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") {
|
||||
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
|
||||
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
|
||||
return errors.New("database-replica-urls can only be used if database-url is also set")
|
||||
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {
|
||||
@@ -508,7 +502,6 @@ func execServe(c *cli.Context) error {
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.DatabaseURL = databaseURL
|
||||
conf.DatabaseReplicaURLs = databaseReplicaURLs
|
||||
conf.WebPushPrivateKey = webPushPrivateKey
|
||||
conf.WebPushPublicKey = webPushPublicKey
|
||||
conf.WebPushFile = webPushFile
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
@@ -380,11 +379,11 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
}
|
||||
if databaseURL != "" {
|
||||
host, dbErr := pg.Open(databaseURL)
|
||||
pool, dbErr := pg.Open(databaseURL)
|
||||
if dbErr != nil {
|
||||
return nil, dbErr
|
||||
}
|
||||
return user.NewPostgresManager(db.New(host, nil), authConfig)
|
||||
return user.NewPostgresManager(pool, authConfig)
|
||||
} else if authFile != "" {
|
||||
if !util.FileExists(authFile) {
|
||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||
|
||||
149
db/db.go
149
db/db.go
@@ -1,137 +1,38 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
const (
|
||||
tag = "db"
|
||||
replicaHealthCheckInitialDelay = 5 * time.Second
|
||||
replicaHealthCheckInterval = 30 * time.Second
|
||||
replicaHealthCheckTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// DB wraps a primary *sql.DB and optional read replicas. All standard query/exec methods
|
||||
// delegate to the primary. The ReadOnly() method returns a *sql.DB from a healthy replica
|
||||
// (round-robin), falling back to the primary if no replicas are configured or all are unhealthy.
|
||||
type DB struct {
|
||||
primary *Host
|
||||
replicas []*Host
|
||||
counter atomic.Uint64
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// New creates a new DB that wraps the given primary and optional replica connections.
|
||||
// If replicas is nil or empty, ReadOnly() simply returns the primary.
|
||||
// Replicas start unhealthy and are checked immediately by a background goroutine.
|
||||
func New(primary *Host, replicas []*Host) *DB {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
d := &DB{
|
||||
primary: primary,
|
||||
replicas: replicas,
|
||||
cancel: cancel,
|
||||
// ExecTx executes a function within a database transaction. If the function returns an error,
|
||||
// the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
func ExecTx(db *sql.DB, f func(tx *sql.Tx) error) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.replicas) > 0 {
|
||||
go d.healthCheckLoop(ctx)
|
||||
defer tx.Rollback()
|
||||
if err := f(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return d
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Query delegates to the primary database.
|
||||
func (d *DB) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return d.primary.DB.Query(query, args...)
|
||||
}
|
||||
|
||||
// QueryRow delegates to the primary database.
|
||||
func (d *DB) QueryRow(query string, args ...any) *sql.Row {
|
||||
return d.primary.DB.QueryRow(query, args...)
|
||||
}
|
||||
|
||||
// Exec delegates to the primary database.
|
||||
func (d *DB) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return d.primary.DB.Exec(query, args...)
|
||||
}
|
||||
|
||||
// Begin delegates to the primary database.
|
||||
func (d *DB) Begin() (*sql.Tx, error) {
|
||||
return d.primary.DB.Begin()
|
||||
}
|
||||
|
||||
// Ping delegates to the primary database.
|
||||
func (d *DB) Ping() error {
|
||||
return d.primary.DB.Ping()
|
||||
}
|
||||
|
||||
// Primary returns the underlying primary *sql.DB. This is only intended for
|
||||
// one-time schema setup during store initialization, not for regular queries.
|
||||
func (d *DB) Primary() *sql.DB {
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// ReadOnly returns a *sql.DB suitable for read-only queries. It round-robins across healthy
|
||||
// replicas. If all replicas are unhealthy or none are configured, the primary is returned.
|
||||
func (d *DB) ReadOnly() *sql.DB {
|
||||
if len(d.replicas) == 0 {
|
||||
return d.primary.DB
|
||||
// QueryTx executes a function within a database transaction and returns the result. If the function
|
||||
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
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
|
||||
}
|
||||
n := len(d.replicas)
|
||||
start := int(d.counter.Add(1) - 1)
|
||||
for i := 0; i < n; i++ {
|
||||
r := d.replicas[(start+i)%n]
|
||||
if r.healthy.Load() {
|
||||
return r.DB
|
||||
}
|
||||
}
|
||||
return d.primary.DB
|
||||
}
|
||||
|
||||
// Close closes the primary database and all replicas, and stops the health-check goroutine.
|
||||
func (d *DB) Close() error {
|
||||
d.cancel()
|
||||
for _, r := range d.replicas {
|
||||
r.DB.Close()
|
||||
}
|
||||
return d.primary.DB.Close()
|
||||
}
|
||||
|
||||
// healthCheckLoop checks replicas immediately, then periodically on a ticker.
|
||||
func (d *DB) healthCheckLoop(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(replicaHealthCheckInitialDelay):
|
||||
d.checkReplicas(ctx)
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(replicaHealthCheckInterval):
|
||||
d.checkReplicas(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkReplicas pings each replica with a timeout and updates its health status.
|
||||
func (d *DB) checkReplicas(ctx context.Context) {
|
||||
for _, r := range d.replicas {
|
||||
wasHealthy := r.healthy.Load()
|
||||
pingCtx, cancel := context.WithTimeout(ctx, replicaHealthCheckTimeout)
|
||||
err := r.DB.PingContext(pingCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
r.healthy.Store(false)
|
||||
log.Tag(tag).Error("Database replica %s is unhealthy: %s", r.Addr, err)
|
||||
} else {
|
||||
r.healthy.Store(true)
|
||||
if !wasHealthy {
|
||||
log.Tag(tag).Info("Database replica %s is healthy", r.Addr)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
71
db/pg/pg.go
71
db/pg/pg.go
@@ -5,84 +5,65 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// Open opens a PostgreSQL connection pool for a primary database. It pings the database
|
||||
// to verify connectivity before returning.
|
||||
func Open(dsn string) (*db.Host, error) {
|
||||
d, err := open(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
if err := d.DB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("database ping failed on %v: %w", d.Addr, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
const (
|
||||
paramMaxOpenConns = "pool_max_conns"
|
||||
paramMaxIdleConns = "pool_max_idle_conns"
|
||||
paramConnMaxLifetime = "pool_conn_max_lifetime"
|
||||
paramConnMaxIdleTime = "pool_conn_max_idle_time"
|
||||
|
||||
// OpenReplica opens a PostgreSQL connection pool for a read replica. Unlike Open, it does
|
||||
// not ping the database, since replicas are health-checked in the background by db.DB.
|
||||
func OpenReplica(dsn string) (*db.Host, error) {
|
||||
return open(dsn)
|
||||
}
|
||||
defaultMaxOpenConns = 10
|
||||
)
|
||||
|
||||
// open opens a PostgreSQL database connection pool from a DSN string. It supports custom
|
||||
// Open opens a PostgreSQL database connection pool from a DSN string. It supports custom
|
||||
// query parameters for pool configuration: pool_max_conns (default 10), pool_max_idle_conns,
|
||||
// pool_conn_max_lifetime, and pool_conn_max_idle_time. These parameters are stripped from
|
||||
// the DSN before passing it to the driver.
|
||||
func open(dsn string) (*db.Host, error) {
|
||||
func Open(dsn string) (*sql.DB, error) {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid database URL: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "postgres", "postgresql":
|
||||
// OK
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid database URL scheme %q, must be \"postgres\" or \"postgresql\" (URL: %s)", u.Scheme, censorPassword(u))
|
||||
}
|
||||
q := u.Query()
|
||||
maxOpenConns, err := extractIntParam(q, "pool_max_conns", 10)
|
||||
maxOpenConns, err := extractIntParam(q, paramMaxOpenConns, defaultMaxOpenConns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxIdleConns, err := extractIntParam(q, "pool_max_idle_conns", 0)
|
||||
maxIdleConns, err := extractIntParam(q, paramMaxIdleConns, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connMaxLifetime, err := extractDurationParam(q, "pool_conn_max_lifetime", 0)
|
||||
connMaxLifetime, err := extractDurationParam(q, paramConnMaxLifetime, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connMaxIdleTime, err := extractDurationParam(q, "pool_conn_max_idle_time", 0)
|
||||
connMaxIdleTime, err := extractDurationParam(q, paramConnMaxIdleTime, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
d, err := sql.Open("pgx", u.String())
|
||||
db, err := sql.Open("pgx", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
if maxIdleConns > 0 {
|
||||
d.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
}
|
||||
if connMaxLifetime > 0 {
|
||||
d.SetConnMaxLifetime(connMaxLifetime)
|
||||
db.SetConnMaxLifetime(connMaxLifetime)
|
||||
}
|
||||
if connMaxIdleTime > 0 {
|
||||
d.SetConnMaxIdleTime(connMaxIdleTime)
|
||||
db.SetConnMaxIdleTime(connMaxIdleTime)
|
||||
}
|
||||
return &db.Host{
|
||||
Addr: u.Host,
|
||||
DB: d,
|
||||
}, nil
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("ping failed: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
|
||||
@@ -98,14 +79,6 @@ func extractIntParam(q url.Values, key string, defaultValue int) (int, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// censorPassword returns a string representation of the URL with the password replaced by "*****".
|
||||
func censorPassword(u *url.URL) string {
|
||||
if password, hasPassword := u.User.Password(); hasPassword {
|
||||
return strings.Replace(u.String(), ":"+password+"@", ":*****@", 1)
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func extractDurationParam(q url.Values, key string, defaultValue time.Duration) (time.Duration, error) {
|
||||
s := q.Get(key)
|
||||
if s == "" {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpen_InvalidScheme(t *testing.T) {
|
||||
_, err := Open("postgresql+psycopg2://user:pass@localhost/db")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), `invalid database URL scheme "postgresql+psycopg2"`)
|
||||
require.Contains(t, err.Error(), "*****")
|
||||
require.NotContains(t, err.Error(), "pass")
|
||||
}
|
||||
|
||||
func TestOpen_InvalidURL(t *testing.T) {
|
||||
_, err := Open("not a valid url\x00")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid database URL")
|
||||
}
|
||||
|
||||
func TestCensorPassword(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with password",
|
||||
url: "postgres://user:secret@localhost/db",
|
||||
expected: "postgres://user:*****@localhost/db",
|
||||
},
|
||||
{
|
||||
name: "without password",
|
||||
url: "postgres://localhost/db",
|
||||
expected: "postgres://localhost/db",
|
||||
},
|
||||
{
|
||||
name: "user only",
|
||||
url: "postgres://user@localhost/db",
|
||||
expected: "postgres://user@localhost/db",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tt.url)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, censorPassword(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package dbtest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
@@ -30,35 +30,34 @@ func CreateTestPostgresSchema(t *testing.T) string {
|
||||
q.Set("pool_max_conns", testPoolMaxConns)
|
||||
u.RawQuery = q.Encode()
|
||||
dsn = u.String()
|
||||
setupHost, err := pg.Open(dsn)
|
||||
setupDB, err := pg.Open(dsn)
|
||||
require.Nil(t, err)
|
||||
_, err = setupHost.DB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
|
||||
_, err = setupDB.Exec(fmt.Sprintf("CREATE SCHEMA %s", schema))
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, setupHost.DB.Close())
|
||||
require.Nil(t, setupDB.Close())
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
schemaDSN := u.String()
|
||||
t.Cleanup(func() {
|
||||
cleanHost, err := pg.Open(dsn)
|
||||
cleanDB, err := pg.Open(dsn)
|
||||
if err == nil {
|
||||
cleanHost.DB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
|
||||
cleanHost.DB.Close()
|
||||
cleanDB.Exec(fmt.Sprintf("DROP SCHEMA %s CASCADE", schema))
|
||||
cleanDB.Close()
|
||||
}
|
||||
})
|
||||
return schemaDSN
|
||||
}
|
||||
|
||||
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *db.DB connection to it.
|
||||
// CreateTestPostgres creates a temporary PostgreSQL schema and returns an open *sql.DB connection to it.
|
||||
// It registers cleanup functions to close the DB and drop the schema when the test finishes.
|
||||
// If NTFY_TEST_DATABASE_URL is not set, the test is skipped.
|
||||
func CreateTestPostgres(t *testing.T) *db.DB {
|
||||
func CreateTestPostgres(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
schemaDSN := CreateTestPostgresSchema(t)
|
||||
testHost, err := pg.Open(schemaDSN)
|
||||
testDB, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
d := db.New(testHost, nil)
|
||||
t.Cleanup(func() {
|
||||
d.Close()
|
||||
testDB.Close()
|
||||
})
|
||||
return d
|
||||
return testDB
|
||||
}
|
||||
|
||||
19
db/types.go
19
db/types.go
@@ -1,19 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Beginner is an interface for types that can begin a database transaction.
|
||||
// Both *sql.DB and *DB implement this.
|
||||
type Beginner interface {
|
||||
Begin() (*sql.Tx, error)
|
||||
}
|
||||
|
||||
// Host pairs a *sql.DB with the host:port it was opened against.
|
||||
type Host struct {
|
||||
Addr string // "host:port"
|
||||
DB *sql.DB
|
||||
healthy atomic.Bool
|
||||
}
|
||||
36
db/util.go
36
db/util.go
@@ -1,36 +0,0 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// ExecTx executes a function within a database transaction. If the function returns an error,
|
||||
// the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
func ExecTx(db Beginner, 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 within a database transaction and returns the result. If the function
|
||||
// returns an error, the transaction is rolled back. Otherwise, the transaction is committed.
|
||||
func QueryTx[T any](db Beginner, 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
|
||||
}
|
||||
309
docs/config.md
309
docs/config.md
@@ -135,268 +135,6 @@ using Docker Compose (i.e. `docker-compose.yml`):
|
||||
command: serve
|
||||
```
|
||||
|
||||
## Config generator
|
||||
|
||||
This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<button type="button" id="cg-open-btn" class="cg-open-btn">Open config generator</button>
|
||||
</div>
|
||||
|
||||
<figure markdown style="padding-left: 50px; padding-right: 50px; cursor: pointer;" onclick="document.getElementById('cg-open-btn').click();">
|
||||
<img src="../../static/img/config-generator.png"/>
|
||||
<figcaption>The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.</figcaption>
|
||||
</figure>
|
||||
|
||||
<div id="cg-modal" class="cg-modal" style="display:none">
|
||||
<div class="cg-modal-backdrop"></div>
|
||||
<div class="cg-modal-dialog">
|
||||
<div class="cg-modal-header">
|
||||
<div class="cg-modal-header-left">
|
||||
<span class="cg-modal-title">Config generator</span><span class="cg-badge-beta">BETA</span>
|
||||
<span class="cg-modal-desc">This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point.</span>
|
||||
</div>
|
||||
<div class="cg-modal-header-actions">
|
||||
<button type="button" id="cg-reset-btn" class="cg-modal-reset" title="Reset all values">Reset</button>
|
||||
<button type="button" id="cg-close-btn" class="cg-modal-close" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-modal-body">
|
||||
<div class="cg-mobile-toggle">
|
||||
<button class="cg-mobile-toggle-btn active" data-show="left">Edit</button>
|
||||
<button class="cg-mobile-toggle-btn" data-show="right">Preview</button>
|
||||
</div>
|
||||
<div id="cg-left">
|
||||
<div class="cg-nav">
|
||||
<div class="cg-nav-tab active" data-panel="cg-panel-general">General</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-database" id="cg-nav-database">Database</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-auth" id="cg-nav-auth">Users</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-cache" id="cg-nav-cache">Message Cache</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-attach" id="cg-nav-attach">Attachments</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-webpush" id="cg-nav-webpush">Web Push</div>
|
||||
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-email" id="cg-nav-email">Email</div>
|
||||
</div>
|
||||
<div class="cg-panels">
|
||||
<div class="cg-panel active" id="cg-panel-general">
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>What URL will ntfy be reachable on?</label>
|
||||
<input type="text" data-key="base-url" placeholder="https://ntfy.example.com">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Will ntfy run behind a proxy (e.g. nginx, Caddy)? <a href="/config/#behind-a-proxy-tls-etc" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-proxy" value="no" checked><span>No</span></label>
|
||||
<label><input type="radio" name="cg-proxy" value="yes"><span>Yes</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Will this ntfy server be open or private? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-server-type" value="open" checked><span>Open</span></label>
|
||||
<label><input type="radio" name="cg-server-type" value="private"><span>Private</span></label>
|
||||
<label><input type="radio" name="cg-server-type" value="custom"><span>Custom</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Will iOS/iPhone users use this server? <a href="/config/#ios-instant-notifications" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-ios" value="no" checked><span>No</span></label>
|
||||
<label><input type="radio" name="cg-ios" value="yes"><span>Yes</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Do you want to use ntfy as a UnifiedPush distributor? <a href="/config/#example-unifiedpush" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-unifiedpush" value="no" checked><span>No</span></label>
|
||||
<label><input type="radio" name="cg-unifiedpush" value="yes"><span>Yes</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field">
|
||||
<label>Which features do you want to enable?</label>
|
||||
<div class="cg-feature-grid">
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-auth"> User management and access control</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-auth">Configure</button></div>
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-cache"> Persistent message cache</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-cache">Configure</button></div>
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-attach"> Attachments</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-attach">Configure</button></div>
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-webpush"> Web push</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-webpush">Configure</button></div>
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-out"> Email notifications</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
|
||||
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-in"> Email publishing</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field cg-hidden" id="cg-wizard-db">
|
||||
<label>Which database backend would you like to use? <a href="/config/#database-options" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-db-type" value="sqlite" checked><span>SQLite</span></label>
|
||||
<label><input type="radio" name="cg-db-type" value="postgres"><span>PostgreSQL</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-auth">
|
||||
<div class="cg-panel-desc">Configure user management, access control, and provisioned users/ACLs. See <a href="/config/#access-control" target="_blank">access control</a> for details.</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Where should the user database be stored?</label>
|
||||
<input type="text" data-key="auth-file" placeholder="/var/lib/ntfy/auth.db">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>What should the default access policy be? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<select id="cg-default-access-select">
|
||||
<option value="read-write" selected>Read & Write</option>
|
||||
<option value="read-only">Read Only</option>
|
||||
<option value="write-only">Write Only</option>
|
||||
<option value="deny-all">Deny All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Should login to the web app be enabled?</label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-login-mode" value="disabled" checked><span>Disabled</span></label>
|
||||
<label><input type="radio" name="cg-login-mode" value="enabled"><span>Enabled</span></label>
|
||||
<label><input type="radio" name="cg-login-mode" value="required"><span>Required</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Should it be possible to sign up via the web app?</label>
|
||||
<div class="cg-btn-group">
|
||||
<label><input type="radio" name="cg-enable-signup" value="no" checked><span>No</span></label>
|
||||
<label><input type="radio" name="cg-enable-signup" value="yes"><span>Yes</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" data-key="auth-default-access">
|
||||
<input type="checkbox" data-key="enable-login" id="cg-enable-login-hidden" style="display:none">
|
||||
<input type="checkbox" data-key="require-login" id="cg-require-login-hidden" style="display:none">
|
||||
<input type="checkbox" data-key="enable-signup" id="cg-enable-signup-hidden" style="display:none">
|
||||
<div class="cg-field">
|
||||
<label>Provisioned users <a href="/config/#users-and-roles" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-repeatable-container" id="cg-auth-users-container"></div>
|
||||
<button type="button" class="cg-btn-add" data-add-type="user">+ Add user</button>
|
||||
</div>
|
||||
<div class="cg-field">
|
||||
<label>Provisioned ACLs <a href="/config/#access-control-list-acl" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-repeatable-container" id="cg-auth-acls-container"></div>
|
||||
<button type="button" class="cg-btn-add" data-add-type="acl">+ Add ACL</button>
|
||||
</div>
|
||||
<div class="cg-field">
|
||||
<label>Provisioned tokens <a href="/config/#access-tokens" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
|
||||
<div class="cg-repeatable-container" id="cg-auth-tokens-container"></div>
|
||||
<button type="button" class="cg-btn-add" data-add-type="token">+ Add token</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-cache">
|
||||
<div class="cg-panel-desc">Configure the message cache to allow devices to retrieve missed notifications. See <a href="/config/#message-cache" target="_blank">message cache</a> for details.</div>
|
||||
<div class="cg-field cg-inline-field" id="cg-cache-file-field">
|
||||
<label>Where should the cache be stored?</label>
|
||||
<input type="text" data-key="cache-file" placeholder="/var/cache/ntfy/cache.db">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>How long should messages be cached?</label>
|
||||
<input type="text" data-key="cache-duration" placeholder="12h">
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-attach">
|
||||
<div class="cg-panel-desc">Allow users to upload and attach files to notifications. See <a href="/config/#attachments" target="_blank">attachments</a> for details.</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Where should attachments be stored?</label>
|
||||
<input type="text" data-key="attachment-cache-dir" placeholder="/var/cache/ntfy/attachments">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Max file size per attachment?</label>
|
||||
<input type="text" data-key="attachment-file-size-limit" placeholder="15M">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Total attachment storage limit?</label>
|
||||
<input type="text" data-key="attachment-total-size-limit" placeholder="5G">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>How long before attachments expire?</label>
|
||||
<input type="text" data-key="attachment-expiry-duration" placeholder="3h">
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-webpush">
|
||||
<div class="cg-panel-desc">Enable browser push notifications via the Web Push API. VAPID keys are generated automatically. See <a href="/config/#web-push" target="_blank">web push</a> for details.</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Where should web push data be stored?</label>
|
||||
<input type="text" data-key="web-push-file" placeholder="/var/lib/ntfy/webpush.db">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Contact email address</label>
|
||||
<input type="text" data-key="web-push-email-address" placeholder="admin@example.com">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Private key</label>
|
||||
<input type="text" data-key="web-push-private-key" placeholder="Auto-generated" readonly>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Public key</label>
|
||||
<input type="text" data-key="web-push-public-key" placeholder="Auto-generated" readonly>
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label></label>
|
||||
<button type="button" id="cg-regen-keys" class="cg-btn-add">Regenerate keys</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-email">
|
||||
<div class="cg-panel-desc">Configure outgoing email notifications and/or incoming email publishing. See <a href="/config/#e-mail-notifications" target="_blank">email notifications</a> and <a href="/config/#e-mail-publishing" target="_blank">email publishing</a> for details.</div>
|
||||
<div id="cg-email-out-section" class="cg-hidden">
|
||||
<div class="cg-field"><label><strong>Outgoing (notifications)</strong></label></div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>SMTP server address</label>
|
||||
<input type="text" data-key="smtp-sender-addr" placeholder="smtp.example.com:587">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Sender email</label>
|
||||
<input type="text" data-key="smtp-sender-from" placeholder="ntfy@example.com">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>SMTP username</label>
|
||||
<input type="text" data-key="smtp-sender-user" placeholder="Username">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>SMTP password</label>
|
||||
<input type="password" data-key="smtp-sender-pass" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
<div id="cg-email-in-section" class="cg-hidden">
|
||||
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Listen address</label>
|
||||
<input type="text" data-key="smtp-server-listen" placeholder=":25">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Domain</label>
|
||||
<input type="text" data-key="smtp-server-domain" placeholder="ntfy.example.com">
|
||||
</div>
|
||||
<div class="cg-field cg-inline-field">
|
||||
<label>Address prefix</label>
|
||||
<input type="text" data-key="smtp-server-addr-prefix" placeholder="ntfy-">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-panel" id="cg-panel-database">
|
||||
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
|
||||
<div class="cg-field">
|
||||
<label>Database URL</label>
|
||||
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" data-key="upstream-base-url">
|
||||
<input type="checkbox" data-key="behind-proxy" id="cg-behind-proxy" style="display:none">
|
||||
</div>
|
||||
</div>
|
||||
<div id="cg-right">
|
||||
<div class="cg-output-tabs">
|
||||
<div class="cg-output-tab active" data-format="server-yml">server.yml</div>
|
||||
<div class="cg-output-tab" data-format="docker-compose">docker-compose.yml</div>
|
||||
<div class="cg-output-tab" data-format="env-vars">Env variables</div>
|
||||
<button type="button" id="cg-copy-btn" class="cg-btn-copy" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
|
||||
</div>
|
||||
<div class="cg-output-wrap">
|
||||
<pre><code id="cg-code"><span class="cg-empty-msg">Configure options on the left to generate your config...</span></code></pre>
|
||||
<div id="cg-warnings" class="cg-hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Database options
|
||||
ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions.
|
||||
You can choose between **SQLite** and **PostgreSQL** as the database backend.
|
||||
@@ -411,7 +149,11 @@ no external dependencies:
|
||||
|
||||
### PostgreSQL (EXPERIMENTAL)
|
||||
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
|
||||
`database-url` option to a PostgreSQL connection string.
|
||||
`database-url` option to a PostgreSQL connection string:
|
||||
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:5432/ntfy"
|
||||
```
|
||||
|
||||
When `database-url` is set, ntfy will use PostgreSQL for the [message cache](#message-cache),
|
||||
[access control](#access-control), and [web push](#web-push) subscriptions instead of SQLite. The `cache-file`,
|
||||
@@ -423,44 +165,11 @@ topics. To restrict access, set `auth-default-access` to `deny-all` (see [access
|
||||
|
||||
You can also set this via the environment variable `NTFY_DATABASE_URL` or the command line flag `--database-url`.
|
||||
|
||||
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
|
||||
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages, checking access permissions, etc)
|
||||
are distributed across the replicas using round-robin, while all writes and correctness-critical reads continue to go
|
||||
to the primary. If a replica becomes unhealthy, ntfy automatically falls back to the primary until the replica recovers.
|
||||
You can also set this via the environment variable `NTFY_DATABASE_REPLICA_URLS` (comma-separated) or the command line
|
||||
flag `--database-replica-urls`.
|
||||
|
||||
Examples:
|
||||
|
||||
=== "Simple"
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:5432/ntfy"
|
||||
```
|
||||
|
||||
=== "With SSL and pool tuning"
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m"
|
||||
```
|
||||
|
||||
=== "With CA certificate"
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:25060/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
|
||||
```
|
||||
|
||||
=== "With read replicas"
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@primary:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
|
||||
database-replica-urls:
|
||||
- "postgres://user:pass@replica1:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
|
||||
- "postgres://user:pass@replica2:5432/ntfy?sslmode=require&sslrootcert=/etc/ntfy/db-ca-cert.pem&pool_max_conns=30"
|
||||
```
|
||||
|
||||
The database URL supports the standard [PostgreSQL connection parameters](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS)
|
||||
as query parameters, such as `sslmode`, `connect_timeout`, `sslcert`, `sslkey`, `sslrootcert`, and `application_name`.
|
||||
See the [pgx driver documentation](https://pkg.go.dev/github.com/jackc/pgx/v5) for the full list of supported parameters.
|
||||
|
||||
In addition, ntfy supports the following custom query parameters to tune the connection pool (these apply to both
|
||||
the primary and replica URLs):
|
||||
In addition, ntfy supports the following custom query parameters to tune the connection pool:
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---------------------------|---------|----------------------------------------------------------------------------------|
|
||||
@@ -469,6 +178,11 @@ the primary and replica URLs):
|
||||
| `pool_conn_max_lifetime` | - | Maximum amount of time a connection may be reused (Go duration, e.g. `5m`, `1h`) |
|
||||
| `pool_conn_max_idle_time` | - | Maximum amount of time a connection may be idle (Go duration, e.g. `30s`, `5m`) |
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50&pool_conn_max_idle_time=5m"
|
||||
```
|
||||
|
||||
## Message cache
|
||||
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
|
||||
@@ -2105,7 +1819,6 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). |
|
||||
| `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). |
|
||||
| `database-replica-urls` | `NTFY_DATABASE_REPLICA_URLS` | *list of strings (connection URLs)* | - | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. See [read replicas](#read-replicas). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) |
|
||||
|
||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.19.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.19.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.18.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.18.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.19.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.19.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.18.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.18.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.19.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.19.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.18.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.18.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.19.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.19.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.18.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.18.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -116,7 +116,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -124,7 +124,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -132,7 +132,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -140,7 +140,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -150,28 +150,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -213,18 +213,18 @@ pkg install go-ntfy
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_darwin_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_darwin_all.tar.gz > ntfy_2.19.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.19.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.19.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_darwin_all.tar.gz > ntfy_2.18.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.18.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.18.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.19.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.18.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -245,7 +245,7 @@ brew install ntfy
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.0/ntfy_2.19.0_windows_amd64.zip),
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
|
||||
@@ -6,33 +6,13 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.19.0 | Mar 15, 2026 |
|
||||
| ntfy server | v2.18.0 | Mar 7, 2026 |
|
||||
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
## ntfy server v2.19.0
|
||||
|
||||
This is a fast-follow release that enables Postgres read replica support.
|
||||
|
||||
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
|
||||
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages,
|
||||
checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and
|
||||
correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back
|
||||
to the primary until the replica recovers.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
|
||||
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
|
||||
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)
|
||||
|
||||
## ntfy server v2.18.0
|
||||
### ntfy server v2.18.0
|
||||
Released March 7, 2026
|
||||
|
||||
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
|
||||
@@ -53,7 +33,7 @@ if things are working (or not working). There is a [one-off migration tool](http
|
||||
|
||||
* 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)
|
||||
|
||||
## ntfy Android v1.24.0
|
||||
### ntfy Android v1.24.0
|
||||
Released March 5, 2026
|
||||
|
||||
This is a tiny release that will revert the "reconnecting ..." behavior of the foreground notification. Lots of people
|
||||
@@ -1775,4 +1755,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
Nothing to see here.
|
||||
_None_
|
||||
|
||||
853
docs/static/css/config-generator.css
vendored
853
docs/static/css/config-generator.css
vendored
@@ -1,853 +0,0 @@
|
||||
/* Config Generator */
|
||||
|
||||
/* Hidden utility */
|
||||
.cg-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Open button */
|
||||
.cg-open-btn {
|
||||
display: inline-block;
|
||||
padding: 8px 20px;
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cg-open-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.cg-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cg-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.cg-modal-dialog {
|
||||
position: absolute;
|
||||
inset: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.cg-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-modal-header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-modal-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-badge-beta {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
margin-left: 8px;
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cg-modal-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cg-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cg-modal-reset {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
font-family: inherit;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.cg-modal-reset:hover {
|
||||
color: #333;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.cg-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cg-modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Modal body: left + right */
|
||||
.cg-modal-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel */
|
||||
#cg-left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #ddd;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cg-nav-tab {
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #777;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
user-select: none;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-nav-tab:hover {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cg-nav-tab.active {
|
||||
color: var(--md-primary-fg-color);
|
||||
border-bottom-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-panels {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.cg-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cg-panel-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cg-panel-desc a {
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-help {
|
||||
color: var(--md-primary-fg-color);
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.cg-help:hover {
|
||||
color: var(--md-primary-fg-color--dark, #2a6e5f);
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
#cg-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cg-output-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #ddd;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cg-output-tab {
|
||||
padding: 9px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
color: #777;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cg-output-tab:hover {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.cg-output-tab.active {
|
||||
color: var(--md-primary-fg-color);
|
||||
border-bottom-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-btn-copy {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
color: #777;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.cg-btn-copy:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cg-output-wrap {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cg-output-wrap pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
flex: 1;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cg-empty-msg {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cg-warning {
|
||||
padding: 6px 10px;
|
||||
margin-top: 8px;
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.cg-field {
|
||||
margin-bottom: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.cg-field:nth-child(odd) {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
.cg-field:nth-child(even) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-field > label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.78rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cg-field input[type="text"],
|
||||
.cg-field input[type="password"],
|
||||
.cg-field select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-field input[type="text"]:focus,
|
||||
.cg-field input[type="password"]:focus,
|
||||
.cg-field select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
.cg-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cg-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-checkbox label {
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cg-radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: 400;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-radio-group input[type="radio"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
/* Inline field: label + control side by side */
|
||||
.cg-inline-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cg-inline-field > label {
|
||||
margin-bottom: 0;
|
||||
width: 60%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.cg-inline-field > input[type="text"],
|
||||
.cg-inline-field > select {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cg-inline-field > input[type="text"]:focus,
|
||||
.cg-inline-field > select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
|
||||
}
|
||||
|
||||
#cg-email-in-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cg-pg-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Button group toggle */
|
||||
.cg-btn-group {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cg-btn-group label {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cg-btn-group input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-btn-group span {
|
||||
display: block;
|
||||
padding: 4px 14px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: 1px solid #ccc;
|
||||
color: #555;
|
||||
background: #fff;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cg-btn-group label:first-child span {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
.cg-btn-group label:last-child span {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
.cg-btn-group label + label span {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.cg-btn-group input[type="radio"]:checked + span {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border-color: var(--md-primary-fg-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cg-feature-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cg-feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cg-feature-grid label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cg-feature-grid input[type="checkbox"] {
|
||||
accent-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
.cg-btn-configure {
|
||||
background: none;
|
||||
border: 1px solid var(--md-primary-fg-color);
|
||||
border-radius: 10px;
|
||||
color: var(--md-primary-fg-color);
|
||||
font-size: 0.68rem;
|
||||
font-family: inherit;
|
||||
padding: 1px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cg-btn-configure:hover {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Repeatable rows */
|
||||
.cg-repeatable-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input,
|
||||
.cg-repeatable-row select {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input:focus,
|
||||
.cg-repeatable-row select:focus {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cg-repeatable-row input:disabled,
|
||||
.cg-repeatable-row select:disabled {
|
||||
background: #eee;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cg-btn-remove {
|
||||
background: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 5px 8px;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cg-btn-remove:hover {
|
||||
background: #fee;
|
||||
border-color: #c66;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
.cg-btn-add {
|
||||
background: none;
|
||||
border: 1px dashed #bbb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cg-btn-add:hover {
|
||||
border-color: var(--md-primary-fg-color);
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
body[data-md-color-scheme="slate"] .cg-modal-dialog {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-header {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-title {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-desc {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-reset {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-reset:hover {
|
||||
border-color: #888;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-close {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-modal-close:hover {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] #cg-left {
|
||||
border-right-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav-tab {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-nav-tab:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tabs {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tab {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-tab:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-copy {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-copy:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-output-wrap pre {
|
||||
background: #161620;
|
||||
color: #ddd;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field:nth-child(odd) {
|
||||
background: #232334;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field:nth-child(even) {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-panel-desc {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field > label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-field input[type="text"],
|
||||
body[data-md-color-scheme="slate"] .cg-field input[type="password"],
|
||||
body[data-md-color-scheme="slate"] .cg-field select {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-group span {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-group input[type="radio"]:checked + span {
|
||||
background: var(--md-primary-fg-color);
|
||||
color: #fff;
|
||||
border-color: var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-checkbox label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-radio-group label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-feature-grid label {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row input,
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row select {
|
||||
background: #2a2a3a;
|
||||
border-color: #555;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row input:disabled,
|
||||
body[data-md-color-scheme="slate"] .cg-repeatable-row select:disabled {
|
||||
background: #1a1a28;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-remove {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-btn-add {
|
||||
border-color: #555;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-warning {
|
||||
background: #3a2e00;
|
||||
color: #ffc107;
|
||||
border-color: #665200;
|
||||
}
|
||||
|
||||
/* Mobile toggle bar (hidden on desktop) */
|
||||
.cg-mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.cg-modal-dialog {
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cg-modal-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.cg-modal-title {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.cg-modal-desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-modal-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle-btn {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cg-mobile-toggle-btn.active {
|
||||
background: #fff;
|
||||
color: var(--md-primary-fg-color);
|
||||
box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);
|
||||
}
|
||||
|
||||
#cg-left {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#cg-right {
|
||||
flex: 1;
|
||||
display: none;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#cg-right.cg-mobile-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#cg-left.cg-mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cg-nav {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cg-inline-field {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cg-inline-field > label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode mobile toggle */
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle {
|
||||
border-bottom-color: #444;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn {
|
||||
background: #2a2a3a;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn.active {
|
||||
background: #1e1e2e;
|
||||
color: var(--md-primary-fg-color);
|
||||
}
|
||||
BIN
docs/static/img/config-generator.png
vendored
BIN
docs/static/img/config-generator.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 318 KiB |
1220
docs/static/js/bcrypt.js
vendored
1220
docs/static/js/bcrypt.js
vendored
File diff suppressed because it is too large
Load Diff
1357
docs/static/js/config-generator.js
vendored
1357
docs/static/js/config-generator.js
vendored
File diff suppressed because it is too large
Load Diff
48
go.mod
48
go.mod
@@ -4,7 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.61.1 // indirect
|
||||
cloud.google.com/go/storage v1.60.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
@@ -14,12 +14,12 @@ require (
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.271.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.269.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -34,14 +34,14 @@ require (
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
@@ -70,7 +70,7 @@ require (
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -86,20 +86,20 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.41.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
|
||||
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
100
go.sum
100
go.sum
@@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg=
|
||||
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.61.1 h1:VELCSvZKiSw0AS1k3so5mKGy3CB7bTCYD8EHhTF42bY=
|
||||
cloud.google.com/go/storage v1.61.1/go.mod h1:k30/hwYfd0M8aULYbPkQLgNf+SFcdjlRHvLMXggw18E=
|
||||
cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
|
||||
cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
|
||||
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
|
||||
github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -165,36 +165,36 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.41.0 h1:MBzEwqhroF0JK0DpTVYWDxsenxm6L4PqOEfA90uZ5AA=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.41.0/go.mod h1:5pSDD0v0t2HqUmPC5cBBc+nLQO4dLYWnzBNheXLBLgs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0 h1:w/o339tDd6Qtu3+ytwt+/jon2yjAs3Ot8Xq8pelfhSo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.66.0/go.mod h1:pdhNtM9C4H5fRdrnwO7NjxzQWhKSSxCHk/KluVqDVC0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -209,10 +209,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -220,8 +220,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -234,8 +234,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -245,8 +245,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -258,10 +258,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
@@ -272,16 +272,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.271.0 h1:cIPN4qcUc61jlh7oXu6pwOQqbJW2GqYh5PS6rB2C/JY=
|
||||
google.golang.org/api v0.271.0/go.mod h1:CGT29bhwkbF+i11qkRUJb2KMKqcJ1hdFceEIRd9u64Q=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c h1:ZhFDeBMmFc/4g8/GwxnJ4rzB3O4GwQVNr+8Mh7Y5z4g=
|
||||
google.golang.org/genproto v0.0.0-20260311181403-84a4fc48630c/go.mod h1:hf4r/rBuzaTkLUWRO03771Xvcs6P5hwdQK3UUEJjqo0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c h1:OyQPd6I3pN/9gDxz6L13kYGJgqkpdrAohJRBeXyxlgI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c/go.mod h1:X2gu9Qwng7Nn009s/r3RUxqkzQNqOrAy79bluY7ojIg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4=
|
||||
google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
|
||||
@@ -50,14 +50,14 @@ type queries struct {
|
||||
|
||||
// Cache stores published messages
|
||||
type Cache struct {
|
||||
db *db.DB
|
||||
db *sql.DB
|
||||
queue *util.BatchingQueue[*model.Message]
|
||||
nop bool
|
||||
mu *sync.Mutex // nil for PostgreSQL (concurrent writes supported), set for SQLite (single writer)
|
||||
queries queries
|
||||
}
|
||||
|
||||
func newCache(db *db.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache {
|
||||
func newCache(db *sql.DB, queries queries, mu *sync.Mutex, batchSize int, batchTimeout time.Duration, nop bool) *Cache {
|
||||
var queue *util.BatchingQueue[*model.Message]
|
||||
if batchSize > 0 || batchTimeout > 0 {
|
||||
queue = util.NewBatchingQueue[*model.Message](batchSize, batchTimeout)
|
||||
@@ -201,11 +201,10 @@ func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool)
|
||||
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
rdb := c.db.ReadOnly()
|
||||
if scheduled {
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceTimeScheduled, topic, since.Time().Unix())
|
||||
} else {
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -216,11 +215,10 @@ func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, schedul
|
||||
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
rdb := c.db.ReadOnly()
|
||||
if scheduled {
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceIDScheduled, topic, since.ID())
|
||||
} else {
|
||||
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
|
||||
rows, err = c.db.Query(c.queries.selectMessagesSinceID, topic, since.ID())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -229,7 +227,7 @@ func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled
|
||||
}
|
||||
|
||||
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesLatest, topic)
|
||||
rows, err := c.db.Query(c.queries.selectMessagesLatest, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -268,7 +266,7 @@ func (c *Cache) MessagesExpired() ([]string, error) {
|
||||
|
||||
// Message returns the message with the given ID, or ErrMessageNotFound if not found
|
||||
func (c *Cache) Message(id string) (*model.Message, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesByID, id)
|
||||
rows, err := c.db.Query(c.queries.selectMessagesByID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -297,7 +295,7 @@ func (c *Cache) MarkPublished(m *model.Message) error {
|
||||
|
||||
// MessagesCount returns the total number of messages in the cache
|
||||
func (c *Cache) MessagesCount() (int, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)
|
||||
rows, err := c.db.Query(c.queries.selectMessagesCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -314,7 +312,7 @@ func (c *Cache) MessagesCount() (int, error) {
|
||||
|
||||
// Topics returns a list of all topics with messages in the cache
|
||||
func (c *Cache) Topics() ([]string, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectTopics)
|
||||
rows, err := c.db.Query(c.queries.selectTopics)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -428,7 +426,7 @@ func (c *Cache) MarkAttachmentsDeleted(ids ...string) error {
|
||||
|
||||
// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender
|
||||
func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
|
||||
rows, err := c.db.Query(c.queries.selectAttachmentsSizeBySender, sender, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -437,7 +435,7 @@ func (c *Cache) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
||||
|
||||
// AttachmentBytesUsedByUser returns the total size of active attachments for the given user
|
||||
func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
|
||||
rows, err := c.db.Query(c.queries.selectAttachmentsSizeByUserID, userID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -468,7 +466,7 @@ func (c *Cache) UpdateStats(messages int64) error {
|
||||
|
||||
// Stats returns the total message count statistic
|
||||
func (c *Cache) Stats() (messages int64, err error) {
|
||||
rows, err := c.db.ReadOnly().Query(c.queries.selectStats)
|
||||
rows, err := c.db.Query(c.queries.selectStats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// PostgreSQL runtime query constants
|
||||
@@ -103,9 +102,9 @@ var postgresQueries = queries{
|
||||
}
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
|
||||
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
func NewPostgresStore(db *sql.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
|
||||
if err := setupPostgres(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
|
||||
return newCache(db, postgresQueries, nil, batchSize, batchTimeout, false), nil
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -111,14 +110,14 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
|
||||
if !util.FileExists(parentDir) {
|
||||
return nil, fmt.Errorf("cache database directory %s does not exist or is not accessible", parentDir)
|
||||
}
|
||||
d, err := sql.Open("sqlite3", filename)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d, startupQueries, cacheDuration); err != nil {
|
||||
if err := setupSQLite(db, startupQueries, cacheDuration); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
|
||||
return newCache(db, sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
|
||||
}
|
||||
|
||||
// NewMemStore creates an in-memory cache
|
||||
|
||||
@@ -42,11 +42,8 @@ extra:
|
||||
link: https://github.com/binwiederhier
|
||||
extra_javascript:
|
||||
- static/js/extra.js
|
||||
- static/js/bcrypt.js
|
||||
- static/js/config-generator.js
|
||||
extra_css:
|
||||
- static/css/extra.css
|
||||
- static/css/config-generator.css
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
|
||||
@@ -95,8 +95,7 @@ type Config struct {
|
||||
ListenUnixMode fs.FileMode
|
||||
KeyFile string
|
||||
CertFile string
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -32,7 +33,6 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
@@ -47,7 +47,7 @@ import (
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
type Server struct {
|
||||
config *Config
|
||||
db *db.DB // Shared PostgreSQL connection pool (with optional replicas), nil when using SQLite
|
||||
db *sql.DB // Shared PostgreSQL connection pool, nil when using SQLite
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
httpMetricsServer *http.Server
|
||||
@@ -179,26 +179,13 @@ func New(conf *Config) (*Server, error) {
|
||||
stripe = newStripeAPI()
|
||||
}
|
||||
// Open shared PostgreSQL connection pool if configured
|
||||
var pool *db.DB
|
||||
var pool *sql.DB
|
||||
if conf.DatabaseURL != "" {
|
||||
primary, err := pg.Open(conf.DatabaseURL)
|
||||
var err error
|
||||
pool, err = pg.Open(conf.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var replicas []*db.Host
|
||||
for _, replicaURL := range conf.DatabaseReplicaURLs {
|
||||
r, err := pg.OpenReplica(replicaURL)
|
||||
if err != nil {
|
||||
// Close already-opened replicas before returning
|
||||
for _, opened := range replicas {
|
||||
opened.DB.Close()
|
||||
}
|
||||
primary.DB.Close()
|
||||
return nil, fmt.Errorf("failed to open database replica: %w", err)
|
||||
}
|
||||
replicas = append(replicas, r)
|
||||
}
|
||||
pool = db.New(primary, replicas)
|
||||
}
|
||||
messageCache, err := createMessageCache(conf, pool)
|
||||
if err != nil {
|
||||
@@ -290,7 +277,7 @@ func New(conf *Config) (*Server, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
|
||||
func createMessageCache(conf *Config, pool *sql.DB) (*message.Cache, error) {
|
||||
if conf.CacheDuration == 0 {
|
||||
return message.NewNopStore()
|
||||
} else if pool != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@ package test
|
||||
import (
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"net"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
|
||||
|
||||
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
||||
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||
port := findAvailablePort(t)
|
||||
port := 10000 + rand.Intn(30000)
|
||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||
conf.AttachmentCacheDir = t.TempDir()
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
@@ -33,17 +33,6 @@ func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, i
|
||||
return s, port
|
||||
}
|
||||
|
||||
// findAvailablePort asks the OS for a free port by binding to :0
|
||||
func findAvailablePort(t *testing.T) int {
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
listener.Close()
|
||||
return port
|
||||
}
|
||||
|
||||
// StopServer stops the test server and waits for the port to be down
|
||||
func StopServer(t *testing.T, s *server.Server, port int) {
|
||||
s.Stop()
|
||||
|
||||
@@ -236,11 +236,10 @@ func execImport(c *cli.Context) error {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
pgHost, err := pg.Open(databaseURL)
|
||||
pgDB, err := pg.Open(databaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot connect to PostgreSQL: %w", err)
|
||||
}
|
||||
pgDB := pgHost.DB
|
||||
defer pgDB.Close()
|
||||
|
||||
if c.Bool("create-schema") {
|
||||
|
||||
@@ -49,7 +49,7 @@ var (
|
||||
// Manager handles user authentication, authorization, and management
|
||||
type Manager struct {
|
||||
config *Config
|
||||
db *db.DB
|
||||
db *sql.DB
|
||||
queries queries
|
||||
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
|
||||
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
|
||||
@@ -58,7 +58,7 @@ type Manager struct {
|
||||
|
||||
var _ Auther = (*Manager)(nil)
|
||||
|
||||
func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
func newManager(db *sql.DB, queries queries, config *Config) (*Manager, error) {
|
||||
if config.BcryptCost <= 0 {
|
||||
config.BcryptCost = DefaultUserPasswordBcryptCost
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
|
||||
}
|
||||
manager := &Manager{
|
||||
config: config,
|
||||
db: d,
|
||||
db: db,
|
||||
statsQueue: make(map[string]*Stats),
|
||||
tokenQueue: make(map[string]*TokenUpdate),
|
||||
queries: queries,
|
||||
@@ -415,7 +415,7 @@ func (a *Manager) userByToken(token string) (*User, error) {
|
||||
|
||||
// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise
|
||||
func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserByStripeCustomerID, customerID)
|
||||
rows, err := a.db.Query(a.queries.selectUserByStripeCustomerID, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -425,7 +425,7 @@ func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {
|
||||
// Users returns a list of users. It loads all users in a single query
|
||||
// rather than one query per user to avoid N+1 performance issues.
|
||||
func (a *Manager) Users() ([]*User, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUsers)
|
||||
rows, err := a.db.Query(a.queries.selectUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -434,7 +434,7 @@ func (a *Manager) Users() ([]*User, error) {
|
||||
|
||||
// UsersCount returns the number of users in the database
|
||||
func (a *Manager) UsersCount() (int64, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserCount)
|
||||
rows, err := a.db.Query(a.queries.selectUserCount)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -660,7 +660,7 @@ func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read,
|
||||
|
||||
// AllGrants returns all user-specific access control entries, mapped to their respective user IDs
|
||||
func (a *Manager) AllGrants() (map[string][]Grant, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserAllAccess)
|
||||
rows, err := a.db.Query(a.queries.selectUserAllAccess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -688,7 +688,7 @@ func (a *Manager) AllGrants() (map[string][]Grant, error) {
|
||||
|
||||
// Grants returns all user-specific access control entries
|
||||
func (a *Manager) Grants(username string) ([]Grant, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserAccess, username)
|
||||
rows, err := a.db.Query(a.queries.selectUserAccess, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -753,7 +753,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
|
||||
|
||||
// Reservations returns all user-owned topics, and the associated everyone-access
|
||||
func (a *Manager) Reservations(username string) ([]Reservation, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservations, Everyone, username)
|
||||
rows, err := a.db.Query(a.queries.selectUserReservations, Everyone, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -796,7 +796,7 @@ func (a *Manager) HasReservation(username, topic string) (bool, error) {
|
||||
|
||||
// ReservationsCount returns the number of reservations owned by this user
|
||||
func (a *Manager) ReservationsCount(username string) (int64, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservationsCount, username)
|
||||
rows, err := a.db.Query(a.queries.selectUserReservationsCount, username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -962,7 +962,7 @@ func (a *Manager) canChangeToken(userID, token string) error {
|
||||
|
||||
// Token returns a specific token for a user
|
||||
func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectToken, userID, token)
|
||||
rows, err := a.db.Query(a.queries.selectToken, userID, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -972,7 +972,7 @@ func (a *Manager) Token(userID, token string) (*Token, error) {
|
||||
|
||||
// Tokens returns all existing tokens for the user with the given user ID
|
||||
func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTokens, userID)
|
||||
rows, err := a.db.Query(a.queries.selectTokens, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -991,7 +991,7 @@ func (a *Manager) Tokens(userID string) ([]*Token, error) {
|
||||
}
|
||||
|
||||
func (a *Manager) allProvisionedTokens() ([]*Token, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectAllProvisionedTokens)
|
||||
rows, err := a.db.Query(a.queries.selectAllProvisionedTokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1114,7 +1114,7 @@ func (a *Manager) RemoveTier(code string) error {
|
||||
|
||||
// Tiers returns a list of all Tier structs
|
||||
func (a *Manager) Tiers() ([]*Tier, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTiers)
|
||||
rows, err := a.db.Query(a.queries.selectTiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1134,7 +1134,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
|
||||
|
||||
// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist
|
||||
func (a *Manager) Tier(code string) (*Tier, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTierByCode, code)
|
||||
rows, err := a.db.Query(a.queries.selectTierByCode, code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1144,7 +1144,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
|
||||
|
||||
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
|
||||
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectTierByPriceID, priceID, priceID)
|
||||
rows, err := a.db.Query(a.queries.selectTierByPriceID, priceID, priceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1185,7 +1185,7 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
|
||||
// PhoneNumbers returns all phone numbers for the user with the given user ID
|
||||
func (a *Manager) PhoneNumbers(userID string) ([]string, error) {
|
||||
rows, err := a.db.ReadOnly().Query(a.queries.selectPhoneNumbers, userID)
|
||||
rows, err := a.db.Query(a.queries.selectPhoneNumbers, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// PostgreSQL queries
|
||||
@@ -278,9 +278,9 @@ var postgresQueries = queries{
|
||||
}
|
||||
|
||||
// NewPostgresManager creates a new Manager backed by a PostgreSQL database
|
||||
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
func NewPostgresManager(db *sql.DB, config *Config) (*Manager, error) {
|
||||
if err := setupPostgres(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newManager(d, postgresQueries, config)
|
||||
return newManager(db, postgresQueries, config)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -281,15 +280,15 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
|
||||
if !util.FileExists(parentDir) {
|
||||
return nil, fmt.Errorf("user database directory %s does not exist or is not accessible", parentDir)
|
||||
}
|
||||
d, err := sql.Open("sqlite3", filename)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d); err != nil {
|
||||
if err := setupSQLite(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newManager(db.New(&db.Host{DB: d}, nil), sqliteQueries, config)
|
||||
return newManager(db, sqliteQueries, config)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/db"
|
||||
"heckel.io/ntfy/v2/db/pg"
|
||||
dbtest "heckel.io/ntfy/v2/db/test"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -37,9 +36,9 @@ func forEachBackend(t *testing.T, f func(t *testing.T, newManager newManagerFunc
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
schemaDSN := dbtest.CreateTestPostgresSchema(t)
|
||||
f(t, func(config *Config) *Manager {
|
||||
host, err := pg.Open(schemaDSN)
|
||||
pool, err := pg.Open(schemaDSN)
|
||||
require.Nil(t, err)
|
||||
a, err := NewPostgresManager(db.New(host, nil), config)
|
||||
a, err := NewPostgresManager(pool, config)
|
||||
require.Nil(t, err)
|
||||
return a
|
||||
})
|
||||
@@ -1735,8 +1734,8 @@ func TestMigrationFrom4(t *testing.T) {
|
||||
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, d *db.DB) {
|
||||
rows, err := d.Query(`SELECT version FROM schemaVersion`)
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
|
||||
@@ -1772,7 +1771,7 @@ func newTestManagerFromConfig(t *testing.T, newManager newManagerFunc, conf *Con
|
||||
return a
|
||||
}
|
||||
|
||||
func testDB(a *Manager) *db.DB {
|
||||
func testDB(a *Manager) *sql.DB {
|
||||
return a.db
|
||||
}
|
||||
|
||||
|
||||
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@@ -4324,9 +4324,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-iterator-helpers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.0.tgz",
|
||||
"integrity": "sha512-04cg8iJFDOxWcYlu0GFFWgs7vtaEPCmr5w1nrj9V3z3axu/48HCMwK6VMp45Zh3ZB+xLP1ifbJfrq86+1ypKKQ==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz",
|
||||
"integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4345,7 +4345,6 @@
|
||||
"has-symbols": "^1.1.0",
|
||||
"internal-slot": "^1.1.0",
|
||||
"iterator.prototype": "^1.1.5",
|
||||
"math-intrinsics": "^1.1.0",
|
||||
"safe-array-concat": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5066,9 +5065,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
"publish_dialog_priority_low": "Prioridad baja",
|
||||
"publish_dialog_priority_high": "Prioridad alta",
|
||||
"publish_dialog_delay_label": "Retraso",
|
||||
"publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco",
|
||||
"publish_dialog_title_placeholder": "Título de la notificación, por ejemplo, Alerta de espacio en disco",
|
||||
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
|
||||
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",
|
||||
|
||||
@@ -8,8 +8,6 @@ import routes from "../components/routes";
|
||||
* support this; most importantly, all iOS browsers do not support window.Notification.
|
||||
*/
|
||||
class Notifier {
|
||||
lastSoundPlayedAt = 0;
|
||||
|
||||
async notify(subscription, notification) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
@@ -51,17 +49,11 @@ class Notifier {
|
||||
}
|
||||
|
||||
async playSound() {
|
||||
// Play sound, but not more than once every 2 seconds
|
||||
const now = Date.now();
|
||||
if (now - this.lastSoundPlayedAt < 2000) {
|
||||
console.log(`[Notifier] Not playing notification sound, since it was last played <2s ago`, this.lastSoundPlayedAt);
|
||||
return;
|
||||
}
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
if (sound && sound !== "none") {
|
||||
try {
|
||||
await playSound(sound);
|
||||
this.lastSoundPlayedAt = Date.now();
|
||||
} catch (e) {
|
||||
console.log(`[Notifier] Error playing audio`, e);
|
||||
}
|
||||
|
||||
@@ -136,11 +136,9 @@ const ChangePassword = () => {
|
||||
⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤
|
||||
</Typography>
|
||||
{!account?.provisioned ? (
|
||||
<Tooltip title={t("account_basics_password_description")}>
|
||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip title={t("account_basics_cannot_edit_or_delete_provisioned_user")}>
|
||||
<span>
|
||||
@@ -901,16 +899,12 @@ const TokensTable = (props) => {
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{token.token !== session.token() && !token.provisioned && (
|
||||
<>
|
||||
<Tooltip title={t("account_tokens_dialog_title_edit")}>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("account_tokens_dialog_title_delete")}>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton onClick={() => handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{token.token === session.token() && (
|
||||
|
||||
@@ -385,16 +385,12 @@ const UserTable = (props) => {
|
||||
<TableCell align="right" sx={{ whiteSpace: "nowrap" }}>
|
||||
{(!session.exists() || user.baseUrl !== config.base_url) && (
|
||||
<>
|
||||
<Tooltip title={t("prefs_users_edit_button")}>
|
||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("prefs_users_delete_button")}>
|
||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{session.exists() && user.baseUrl === config.base_url && (
|
||||
@@ -742,16 +738,12 @@ const ReservationsTable = (props) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t("prefs_reservations_edit_button")}>
|
||||
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("prefs_reservations_delete_button")}>
|
||||
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -24,7 +24,7 @@ var (
|
||||
|
||||
// Store holds the database connection and queries for web push subscriptions.
|
||||
type Store struct {
|
||||
db *db.DB
|
||||
db *sql.DB
|
||||
queries queries
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string,
|
||||
|
||||
// SubscriptionsForTopic returns all subscriptions for the given topic.
|
||||
func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) {
|
||||
rows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsForTopic, topic)
|
||||
rows, err := s.db.Query(s.queries.selectSubscriptionsForTopic, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (s *Store) SubscriptionsForTopic(topic string) ([]*Subscription, error) {
|
||||
|
||||
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period.
|
||||
func (s *Store) SubscriptionsExpiring(warnAfter time.Duration) ([]*Subscription, error) {
|
||||
rows, err := s.db.ReadOnly().Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix())
|
||||
rows, err := s.db.Query(s.queries.selectSubscriptionsExpiringSoon, time.Now().Add(-warnAfter).Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -73,12 +73,12 @@ const (
|
||||
)
|
||||
|
||||
// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool.
|
||||
func NewPostgresStore(d *db.DB) (*Store, error) {
|
||||
if err := setupPostgres(d.Primary()); err != nil {
|
||||
func NewPostgresStore(db *sql.DB) (*Store, error) {
|
||||
if err := setupPostgres(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{
|
||||
db: d,
|
||||
db: db,
|
||||
queries: queries{
|
||||
selectSubscriptionIDByEndpoint: postgresSelectSubscriptionIDByEndpointQuery,
|
||||
selectSubscriptionCountBySubscriberIP: postgresSelectSubscriptionCountBySubscriberIPQuery,
|
||||
@@ -97,11 +97,11 @@ func NewPostgresStore(d *db.DB) (*Store, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupPostgres(d *sql.DB) error {
|
||||
func setupPostgres(db *sql.DB) error {
|
||||
var schemaVersion int
|
||||
err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
|
||||
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
|
||||
if err != nil {
|
||||
return setupNewPostgres(d)
|
||||
return setupNewPostgres(db)
|
||||
}
|
||||
if schemaVersion > pgCurrentSchemaVersion {
|
||||
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
|
||||
@@ -109,8 +109,8 @@ func setupPostgres(d *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewPostgres(d *sql.DB) error {
|
||||
return db.ExecTx(d, func(tx *sql.Tx) error {
|
||||
func setupNewPostgres(sqlDB *sql.DB) error {
|
||||
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -79,18 +79,18 @@ const (
|
||||
|
||||
// NewSQLiteStore creates a new SQLite-backed web push store.
|
||||
func NewSQLiteStore(filename, startupQueries string) (*Store, error) {
|
||||
d, err := sql.Open("sqlite3", filename)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSQLite(d); err != nil {
|
||||
if err := setupSQLite(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
|
||||
if err := runSQLiteStartupQueries(db, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{
|
||||
db: db.New(&db.Host{DB: d}, nil),
|
||||
db: db,
|
||||
queries: queries{
|
||||
selectSubscriptionIDByEndpoint: sqliteSelectSubscriptionIDByEndpointQuery,
|
||||
selectSubscriptionCountBySubscriberIP: sqliteSelectSubscriptionCountBySubscriberIPQuery,
|
||||
|
||||
Reference in New Issue
Block a user