Merge branch 'main' into macos-server

pull/247/head
Philipp Heckel 2022-05-13 14:59:35 -04:00
commit 891257cce8
9 changed files with 90 additions and 36 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
dist/ dist/
build/ build/
.idea/ .idea/
*.swp
server/docs/ server/docs/
server/site/ server/site/
tools/fbsend/fbsend tools/fbsend/fbsend

View File

@ -5,15 +5,16 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/server"
"heckel.io/ntfy/util"
"log" "log"
"math" "math"
"net" "net"
"strings" "strings"
"time" "time"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/server"
"heckel.io/ntfy/util"
) )
func init() { func init() {
@ -39,7 +40,7 @@ var flagsServe = []cli.Flag{
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
@ -144,12 +145,14 @@ func execServe(c *cli.Context) error {
return errors.New("if set, base-url must start with http:// or https://") return errors.New("if set, base-url must start with http:// or https://")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) { } else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} else if !util.InStringList([]string{"app", "home"}, webRoot) { } else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
return errors.New("if set, web-root must be 'home' or 'app'") return errors.New("if set, web-root must be 'home' or 'app'")
} }
// Default auth permissions
webRootIsApp := webRoot == "app" webRootIsApp := webRoot == "app"
enableWeb := webRoot != "disable"
// Default auth permissions
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
@ -229,6 +232,7 @@ func execServe(c *cli.Context) error {
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
conf.BehindProxy = behindProxy conf.BehindProxy = behindProxy
conf.EnableWeb = enableWeb
s, err := server.New(conf) s, err := server.New(conf)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)

View File

@ -802,7 +802,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) | | `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | | `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |

View File

@ -9,6 +9,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Features:** **Features:**
* [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112)) * [Windows](https://ntfy.sh/docs/install/#windows) and [macOS](https://ntfy.sh/docs/install/#macos) builds for the [ntfy CLI](https://ntfy.sh/docs/subscribe/cli/) ([#112](https://github.com/binwiederhier/ntfy/issues/112))
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
**Additional translations:** **Additional translations:**

View File

@ -88,6 +88,7 @@ type Config struct {
VisitorEmailLimitBurst int VisitorEmailLimitBurst int
VisitorEmailLimitReplenish time.Duration VisitorEmailLimitReplenish time.Duration
BehindProxy bool BehindProxy bool
EnableWeb bool
} }
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
@ -126,5 +127,6 @@ func NewConfig() *Config {
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
BehindProxy: false, BehindProxy: false,
EnableWeb: true,
} }
} }

View File

@ -8,11 +8,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"io" "io"
"log" "log"
"net" "net"
@ -28,6 +23,12 @@ import (
"sync" "sync"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
) )
// Server is the main server, providing the UI and API for ntfy // Server is the main server, providing the UI and API for ntfy
@ -263,23 +264,23 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Method == http.MethodGet && r.URL.Path == "/" { if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.handleHome(w, r) return s.ensureWebEnabled(s.handleHome)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
return s.handleExample(w, r) return s.ensureWebEnabled(s.handleExample)(w, r, v)
} else if r.Method == http.MethodHead && r.URL.Path == "/" { } else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.handleEmpty(w, r, v) return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.handleWebConfig(w, r) return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
return s.handleUserStats(w, r, v) return s.handleUserStats(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r) return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.handleDocs(w, r) return s.ensureWebEnabled(s.handleDocs)(w, r, v)
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
return s.limitRequests(s.handleFile)(w, r, v) return s.limitRequests(s.handleFile)(w, r, v)
} else if r.Method == http.MethodOptions { } else if r.Method == http.MethodOptions {
return s.handleOptions(w, r) return s.ensureWebEnabled(s.handleOptions)(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" { } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v) return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) { } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
@ -297,21 +298,21 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) { } else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v) return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) { } else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
return s.handleTopic(w, r) return s.ensureWebEnabled(s.handleTopic)(w, r, v)
} }
return errHTTPNotFound return errHTTPNotFound
} }
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.WebRootIsApp { if s.config.WebRootIsApp {
r.URL.Path = webAppIndex r.URL.Path = webAppIndex
} else { } else {
r.URL.Path = webHomeIndex r.URL.Path = webHomeIndex
} }
return s.handleStatic(w, r) return s.handleStatic(w, r, v)
} }
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request, v *visitor) error {
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too! unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
if unifiedpush { if unifiedpush {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -320,7 +321,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
r.URL.Path = webAppIndex r.URL.Path = webAppIndex
return s.handleStatic(w, r) return s.handleStatic(w, r, v)
} }
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error { func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
@ -334,12 +335,12 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
return err return err
} }
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error { func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
_, err := io.WriteString(w, exampleSource) _, err := io.WriteString(w, exampleSource)
return err return err
} }
func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
appRoot := "/" appRoot := "/"
if !s.config.WebRootIsApp { if !s.config.WebRootIsApp {
appRoot = "/app" appRoot = "/app"
@ -367,13 +368,13 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
return nil return nil
} }
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path r.URL.Path = webSiteDir + r.URL.Path
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
return nil return nil
} }
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
return nil return nil
} }
@ -904,7 +905,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
return sinceNoMessages, errHTTPBadRequestSinceInvalid return sinceNoMessages, errHTTPBadRequestSinceInvalid
} }
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error { func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
@ -1118,6 +1119,15 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
} }
} }
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if !s.config.EnableWeb {
return errHTTPNotFound
}
return next(w, r, v)
}
}
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers // transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// before passing it on to the next handler. This is meant to be used in combination with handlePublish. // before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc { func (s *Server) transformBodyJSON(next handleFunc) handleFunc {

View File

@ -127,7 +127,8 @@
# manager-interval: "1m" # manager-interval: "1m"
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the # Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home". # web app. If you self-host, you don't want to change this.
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
# #
# web-root: app # web-root: app

View File

@ -6,9 +6,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -18,6 +15,10 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
) )
func TestServer_PublishAndPoll(t *testing.T) { func TestServer_PublishAndPoll(t *testing.T) {
@ -162,6 +163,40 @@ func TestServer_StaticSites(t *testing.T) {
require.Contains(t, rr.Body.String(), "</html>") require.Contains(t, rr.Body.String(), "</html>")
} }
func TestServer_WebEnabled(t *testing.T) {
conf := newTestConfig(t)
conf.EnableWeb = false
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/example.html", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/config.js", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
conf2.EnableWeb = true
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/example.html", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/config.js", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 200, rr.Code)
}
func TestServer_PublishLargeMessage(t *testing.T) { func TestServer_PublishLargeMessage(t *testing.T) {
c := newTestConfig(t) c := newTestConfig(t)
c.AttachmentCacheDir = "" // Disable attachments c.AttachmentCacheDir = "" // Disable attachments
@ -1303,7 +1338,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
} else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" { } else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" {
filename := filepath.Join(t.TempDir(), "firebase.json") filename := filepath.Join(t.TempDir(), "firebase.json")
require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0600)) require.NotNil(t, os.WriteFile(filename, []byte(os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT")), 0o600))
return filename return filename
} }
t.SkipNow() t.SkipNow()

View File

@ -458,7 +458,7 @@ const Language = () => {
<MenuItem value="id">Bahasa Indonesia</MenuItem> <MenuItem value="id">Bahasa Indonesia</MenuItem>
<MenuItem value="ja">日本語</MenuItem> <MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem> <MenuItem value="nb_NO">Norsk bokmål</MenuItem>
<MenuItem value="pt_BR">Português</MenuItem> <MenuItem value="pt_BR">Português (Brasil)</MenuItem>
<MenuItem value="ru">Русский</MenuItem> <MenuItem value="ru">Русский</MenuItem>
<MenuItem value="tr">Türkçe</MenuItem> <MenuItem value="tr">Türkçe</MenuItem>
</Select> </Select>