From 96846295495a9c10c421b2dc4ec01b80e2049cbc Mon Sep 17 00:00:00 2001 From: Curid Date: Fri, 13 May 2022 17:08:07 +0000 Subject: [PATCH 1/3] Add disable option to web-root Closes #238 --- .gitignore | 1 + cmd/serve_linux.go | 14 +++++++++----- docs/config.md | 2 +- server/config.go | 2 ++ server/server.go | 21 +++++++++++---------- server/server.yml | 3 ++- server/server_test.go | 43 +++++++++++++++++++++++++++++++++++++++---- 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index edeaf048..a4422373 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ dist/ build/ .idea/ +*.swp server/docs/ server/site/ tools/fbsend/fbsend diff --git a/cmd/serve_linux.go b/cmd/serve_linux.go index b56268b4..0d838665 100644 --- a/cmd/serve_linux.go +++ b/cmd/serve_linux.go @@ -3,15 +3,16 @@ package cmd import ( "errors" "fmt" - "github.com/urfave/cli/v2" - "github.com/urfave/cli/v2/altsrc" - "heckel.io/ntfy/server" - "heckel.io/ntfy/util" "log" "math" "net" "strings" "time" + + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/server" + "heckel.io/ntfy/util" ) func init() { @@ -146,8 +147,10 @@ func execServe(c *cli.Context) error { return errors.New("if set, web-root must be 'home' or 'app'") } - // Default auth permissions webRootIsApp := webRoot == "app" + enableWeb := webRoot != "disable" + + // Default auth permissions authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only" authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only" @@ -227,6 +230,7 @@ func execServe(c *cli.Context) error { conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish conf.BehindProxy = behindProxy + conf.EnableWeb = enableWeb s, err := server.New(conf) if err != nil { log.Fatalln(err) diff --git a/docs/config.md b/docs/config.md index 448efddf..59d600e4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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-` | | `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. | -| `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` or `home` | `app` | Sets web root to landing page (home), web app (app) or (disable) for no WebUI. | | `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-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`. | diff --git a/server/config.go b/server/config.go index e866e17a..ea34c6af 100644 --- a/server/config.go +++ b/server/config.go @@ -88,6 +88,7 @@ type Config struct { VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration BehindProxy bool + EnableWeb bool } // NewConfig instantiates a default new server config @@ -126,5 +127,6 @@ func NewConfig() *Config { VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, BehindProxy: false, + EnableWeb: true, } } diff --git a/server/server.go b/server/server.go index 4b40db45..4ba21bb8 100644 --- a/server/server.go +++ b/server/server.go @@ -8,11 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/emersion/go-smtp" - "github.com/gorilla/websocket" - "golang.org/x/sync/errgroup" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" "io" "log" "net" @@ -28,6 +23,12 @@ import ( "sync" "time" "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 @@ -262,19 +263,19 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { } 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 == "/" && s.config.EnableWeb { return s.handleHome(w, r) - } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { + } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" && s.config.EnableWeb { return s.handleExample(w, r) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return 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 && s.config.EnableWeb { return s.handleWebConfig(w, r) } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { 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) && s.config.EnableWeb { return s.handleStatic(w, r) - } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { + } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) && s.config.EnableWeb { return s.handleDocs(w, r) } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { return s.limitRequests(s.handleFile)(w, r, v) diff --git a/server/server.yml b/server/server.yml index 3265c751..92516d6f 100644 --- a/server/server.yml +++ b/server/server.yml @@ -127,7 +127,8 @@ # manager-interval: "1m" # 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 WebUI. # # web-root: app diff --git a/server/server_test.go b/server/server_test.go index 0f84b90a..06f3cd2d 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -6,9 +6,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/auth" - "heckel.io/ntfy/util" "math/rand" "net/http" "net/http/httptest" @@ -18,6 +15,10 @@ import ( "sync" "testing" "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/auth" + "heckel.io/ntfy/util" ) func TestServer_PublishAndPoll(t *testing.T) { @@ -162,6 +163,40 @@ func TestServer_StaticSites(t *testing.T) { require.Contains(t, rr.Body.String(), "") } +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) { c := newTestConfig(t) c.AttachmentCacheDir = "" // Disable attachments @@ -1303,7 +1338,7 @@ func firebaseServiceAccountFile(t *testing.T) string { return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") } else if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT") != "" { 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 } t.SkipNow() From c684a3919164e7d4c42f669d5efe0d98b349047a Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 13 May 2022 14:42:25 -0400 Subject: [PATCH 2/3] Fine tuning --- cmd/serve_linux.go | 4 ++-- docs/config.md | 2 +- server/server.go | 53 +++++++++++++++++++++++++++------------------- server/server.yml | 2 +- 4 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cmd/serve_linux.go b/cmd/serve_linux.go index 0d838665..656e3f0f 100644 --- a/cmd/serve_linux.go +++ b/cmd/serve_linux.go @@ -38,7 +38,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: "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.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-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)"}), @@ -143,7 +143,7 @@ func execServe(c *cli.Context) error { 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) { 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'") } diff --git a/docs/config.md b/docs/config.md index 59d600e4..e5b65ac8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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-` | | `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. | -| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home), web app (app) or (disable) for no WebUI. | +| `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. | | `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`. | diff --git a/server/server.go b/server/server.go index 4ba21bb8..1a643c23 100644 --- a/server/server.go +++ b/server/server.go @@ -263,24 +263,24 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { - if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.EnableWeb { - return s.handleHome(w, r) - } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" && s.config.EnableWeb { - return s.handleExample(w, r) + if r.Method == http.MethodGet && r.URL.Path == "/" { + return s.ensureWebEnabled(s.handleHome)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { + return s.ensureWebEnabled(s.handleExample)(w, r, v) } else if r.Method == http.MethodHead && r.URL.Path == "/" { - return s.handleEmpty(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath && s.config.EnableWeb { - return s.handleWebConfig(w, r) + return s.ensureWebEnabled(s.handleEmpty)(w, r, v) + } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { + return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { return s.handleUserStats(w, r, v) - } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) && s.config.EnableWeb { - return s.handleStatic(w, r) - } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) && s.config.EnableWeb { - return s.handleDocs(w, r) + } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { + return s.ensureWebEnabled(s.handleStatic)(w, r, v) + } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { + return s.ensureWebEnabled(s.handleDocs)(w, r, v) } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { return s.limitRequests(s.handleFile)(w, r, v) } 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 == "/" { 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) { @@ -298,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) { 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)) { - return s.handleTopic(w, r) + return s.ensureWebEnabled(s.handleTopic)(w, r, v) } 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 { r.URL.Path = webAppIndex } else { 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! if unifiedpush { w.Header().Set("Content-Type", "application/json") @@ -321,7 +321,7 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error { return err } 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 { @@ -335,12 +335,12 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi 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) 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 := "/" if !s.config.WebRootIsApp { appRoot = "/app" @@ -368,13 +368,13 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi 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 util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) 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) return nil } @@ -905,7 +905,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) { 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-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? @@ -1119,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 // 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 { diff --git a/server/server.yml b/server/server.yml index 92516d6f..233ce1b0 100644 --- a/server/server.yml +++ b/server/server.yml @@ -128,7 +128,7 @@ # 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), "home" or "disable" to disable the WebUI. +# Can be "app" (default), "home" or "disable" to disable the web app entirely. # # web-root: app From 4cae237b3633646744e070e6797b204736fbffe7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 13 May 2022 14:46:30 -0400 Subject: [PATCH 3/3] Changelog --- docs/releases.md | 1 + web/src/components/Preferences.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 1e764abb..2b7970e2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -9,6 +9,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **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)) +* 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:** diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index f679fb42..b8f82766 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -458,7 +458,7 @@ const Language = () => { Bahasa Indonesia 日本語 Norsk bokmål - Português + Português (Brasil) Русский Türkçe