Files
gitea/modules/setting/setting.go
mohammad rahimi 13d0f24423 feat: Replace SSE with WebSocket for UI notifications (#36965)
* Closes #36942
* Fixes #19265 

Replaces the SSE-based push channel (`/user/events`) with a WebSocket
endpoint (`/-/ws`).

### What changes

- **New `/-/ws` endpoint** (authenticated). One WebSocket per origin,
shared across tabs via a single `SharedWorker`.
- **Pubsub broker** (`services/pubsub`) for fan-out by topic, behind a
`Broker` interface. `MemoryBroker` is the default (single process); a
Redis backend is available for multi-process setups, configured via
`[websocket].PUBSUB_TYPE` / `PUBSUB_CONN_STR`. The internal Gitea queue
was not usable here because it has FIFO/single-consumer semantics.
- **Push-only event production.** Events are emitted by write-triggered
notifiers — `NotificationCountChange`, `PublishStopwatchesForUser`, and
the logout publisher — wired into the existing `notify.Notifier`
interface. No server-side pollers.
- **Typed pub/sub on the client.** `web_src/js/modules/worker.ts` is a
singleton transport; features subscribe per event type via
`onUserEvent('notification-count', cb)` instead of branching on
`event.data.type`.
- **Wire contract** (`UserEventType` union) is shared between the worker
and consumers via `web_src/js/types.ts`, kept in sync with
`services/websocket/events.go`.
- **Client-side periodic polling fallback** kicks in only when the
WebSocket cannot be established (e.g. proxy blocks WS, browser lacks
module-SharedWorker support).

### What's removed

- `modules/eventsource` (SSE manager, run loop, messenger).
- `/user/events` route and `tests/integration/eventsource_test.go`.
- All server-side polling for stopwatches and notification counts.

### Stopwatch multi-tab fix

The navbar stopwatch icon was previously rendered conditionally on `{{if
$activeStopwatch}}`, so tabs loaded before the timer started had no DOM
element to update. The icon and popup are now always rendered (toggled
with `tw-hidden`), and the start/stop/cancel handlers POST silently so
all open tabs reflect the change in real time.

### Deployment note

WebSocket needs the upgrade headers to pass through a reverse proxy,
e.g. for nginx:

```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
```

Without them the WebSocket cannot be established, and after 3
consecutive failed opens the shared worker signals `push-unavailable`:
the notification count and stopwatch fall back to periodic polling on
the existing `[ui.notification]` timeouts. Real-time push is lost, the
features keep working. The reverse-proxy docs need the same note (see
the `docs-update-needed` label).

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: Epid <rexmrj@gmail.com>
2026-07-27 07:46:00 +00:00

262 lines
9.0 KiB
Go

// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2017 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"fmt"
"os"
"runtime"
"strings"
"time"
"gitea.dev/modules/log"
"gitea.dev/modules/optional"
"gitea.dev/modules/user"
"gitea.dev/modules/util"
)
const IsWindows = runtime.GOOS == "windows"
var (
// AppVer is the version of the current build of Gitea. It is set in main.go from main.Version.
AppVer string
// AppBuiltWith represents a human-readable version go runtime build version and build tags. (See main.go formatBuiltWith().)
AppBuiltWith string
// AppStartTime store time gitea has started
AppStartTime time.Time
CfgProvider ConfigProvider
// IsInTesting indicates whether the testing is running (unit test or integration test). It can be used for:
// * Skip nonsense error logs during testing caused by unreliable code (TODO: this is only a temporary solution, we should make the test code more reliable)
// * Panic in dev or testing mode to make the problem more obvious and easier to debug
// * Mock some functions or options to make testing easier (eg: session store, time, URL detection, etc.)
IsInTesting = false
)
func init() {
if AppVer == "" {
AppVer = "dev"
}
// FIXME: the logger shouldn't be initialized here, the app entry should initialize the logger
// By default set this logger at Info - we'll change it later, but we need to start with something.
log.SetupStderrLogger(log.DEFAULT, "console-stderr", log.INFO)
}
// IsRunUserMatchCurrentUser returns false if configured run user does not match
// actual user that runs the app. The first return value is the actual user name.
// This check is ignored under Windows since SSH remote login is not the main
// method to login on Windows.
func IsRunUserMatchCurrentUser(runUser string) (string, bool) {
if IsWindows || SSH.StartBuiltinServer {
return "", true
}
currentUser := user.CurrentUsername()
return currentUser, runUser == currentUser
}
func IsInE2eTesting() bool {
return os.Getenv("GITEA_TEST_E2E") == "true"
}
// PrepareAppDataPath creates app data directory if necessary
func PrepareAppDataPath() error {
// FIXME: There are too many calls to MkdirAll in old code. It is incorrect.
// For example, if someDir=/mnt/vol1/gitea-home/data, if the mount point /mnt/vol1 is not mounted when Gitea runs,
// then gitea will make new empty directories in /mnt/vol1, all are stored in the root filesystem.
// The correct behavior should be: creating parent directories is end users' duty. We only create sub-directories in existing parent directories.
// For quickstart, the parent directories should be created automatically for first startup (eg: a flag or a check of INSTALL_LOCK).
// Now we can take the first step to do correctly (using Mkdir) in other packages, and prepare the AppDataPath here, then make a refactor in future.
st, err := os.Stat(AppDataPath)
if os.IsNotExist(err) {
err = os.MkdirAll(AppDataPath, os.ModePerm)
if err != nil {
return fmt.Errorf("unable to create the APP_DATA_PATH directory: %q, Error: %w", AppDataPath, err)
}
return nil
}
if err != nil {
return fmt.Errorf("unable to use APP_DATA_PATH %q. Error: %w", AppDataPath, err)
}
if !st.IsDir() /* also works for symlink */ {
return fmt.Errorf("the APP_DATA_PATH %q is not a directory (or symlink to a directory) and can't be used", AppDataPath)
}
return nil
}
func InitCfgProvider(file string) {
var err error
if CfgProvider, err = NewConfigProviderFromFile(file); err != nil {
log.Fatal("Unable to init config provider from %q: %v", file, err)
}
CfgProvider.DisableSaving() // do not allow saving the CfgProvider into file, it will be polluted by the "MustXxx" calls
}
func MustInstalled() {
if !InstallLock {
log.Fatal(`Unable to load config file for a installed Gitea instance, you should either use "--config" to set your config file (app.ini), or run "gitea web" command to install Gitea.`)
}
}
func LoadCommonSettings() {
if err := loadCommonSettingsFrom(CfgProvider); err != nil {
log.Fatal("Unable to load settings from config: %v", err)
}
}
// loadCommonSettingsFrom loads common configurations from a configuration provider.
func loadCommonSettingsFrom(cfg ConfigProvider) error {
// a lot of logic depends on InstallLock value, so it must be loaded before any other settings
InstallLock = HasInstallLock(cfg)
// WARNING: don't change the sequence except you know what you are doing.
loadRunModeFrom(cfg)
loadLogGlobalFrom(cfg)
loadServerFrom(cfg)
loadSSHFrom(cfg)
mustCurrentRunUserMatch(cfg) // it depends on the SSH config, only non-builtin SSH server requires this check
loadOAuth2From(cfg)
loadSecurityFrom(cfg)
if err := loadAttachmentFrom(cfg); err != nil {
return err
}
if err := loadLFSFrom(cfg); err != nil {
return err
}
loadTimeFrom(cfg)
loadRepositoryFrom(cfg)
if err := loadAvatarsFrom(cfg); err != nil {
return err
}
if err := loadRepoAvatarFrom(cfg); err != nil {
return err
}
if err := loadPackagesFrom(cfg); err != nil {
return err
}
if err := loadActionsFrom(cfg); err != nil {
return err
}
loadUIFrom(cfg)
loadAdminFrom(cfg)
loadAPIFrom(cfg)
loadMetricsFrom(cfg)
loadCamoFrom(cfg)
loadI18nFrom(cfg)
loadGitFrom(cfg)
loadMirrorFrom(cfg)
loadMarkupFrom(cfg)
loadRedisFrom(cfg)
loadGlobalLockFrom(cfg)
loadOtherFrom(cfg)
return nil
}
func loadRunModeFrom(rootCfg ConfigProvider) {
rootSec := rootCfg.Section("")
mustNotRunAsRoot(rootSec)
runModeValue := os.Getenv("GITEA_RUN_MODE")
runModeValue = util.IfZero(runModeValue, rootSec.Key("RUN_MODE").String())
// non-dev mode is treated as prod mode, to protect users from accidentally running in dev mode if there is a typo in this value.
IsProd = !strings.EqualFold(runModeValue, "dev") // TODO: can use case-sensitive comparing in the future
RunMode = util.Iif(IsProd, "prod", "dev")
// there is a separate check: mustCurrentRunUserMatch (IsRunUserMatchCurrentUser)
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
}
func mustNotRunAsRoot(rootSec ConfigSection) {
if os.Getuid() != 0 {
return
}
mustRunAsRoot := os.Getenv("SNAP") != "" && os.Getenv("SNAP_NAME") != "" // snap container runs the app as uid=0
if mustRunAsRoot {
return
}
// The following is a purposefully undocumented option. Please do not run Gitea as root. It will only cause future headaches.
// Please don't use root as a bandaid to "fix" something that is broken, instead the broken thing should instead be fixed properly.
allowRunAsRoot := ConfigSectionKeyBool(rootSec, "I_AM_BEING_UNSAFE_RUNNING_AS_ROOT") || // check gitea config
optional.ParseBool(os.Getenv("GITEA_I_AM_BEING_UNSAFE_RUNNING_AS_ROOT")).Value() // check gitea env var
if !allowRunAsRoot {
// Special thanks to VLC which inspired the wording of this messaging.
log.Fatal("Gitea is not supposed to be run as root. If you need to use privileged TCP ports please instead use `setcap` and the `cap_net_bind_service` permission.")
}
log.Warn("You are running Gitea using the root user, and have purposely chosen to skip built-in protections around this. You have been warned against this.")
}
// HasInstallLock checks the install-lock in ConfigProvider directly, because sometimes the config file is not loaded into setting variables yet.
func HasInstallLock(rootCfg ConfigProvider) bool {
return rootCfg.Section("security").Key("INSTALL_LOCK").MustBool(false)
}
func mustCurrentRunUserMatch(rootCfg ConfigProvider) {
// Does not check run user when the "InstallLock" is off.
if HasInstallLock(rootCfg) {
currentUser, match := IsRunUserMatchCurrentUser(RunUser)
if !match {
log.Fatal("Expect user '%s' (RUN_USER in app.ini) but current user is: %s", RunUser, currentUser)
}
}
}
// LoadSettings initializes the settings for normal start up
func LoadSettings() {
initAllLoggers()
loadDBSetting(CfgProvider)
loadServiceFrom(CfgProvider)
loadOAuth2ClientFrom(CfgProvider)
loadCacheFrom(CfgProvider)
loadWebsocketFrom(CfgProvider)
loadSessionFrom(CfgProvider)
loadCorsFrom(CfgProvider)
loadMailsFrom(CfgProvider)
loadProxyFrom(CfgProvider)
loadWebhookFrom(CfgProvider)
loadMigrationsFrom(CfgProvider)
loadIndexerFrom(CfgProvider)
loadTaskFrom(CfgProvider)
LoadQueueSettings()
loadProjectFrom(CfgProvider)
loadMimeTypeMapFrom(CfgProvider)
loadFederationFrom(CfgProvider)
}
// LoadSettingsForInstall initializes the settings for install
func LoadSettingsForInstall() {
loadDBSetting(CfgProvider)
loadServiceFrom(CfgProvider)
loadMailsFrom(CfgProvider)
}
var configuredPaths = make(map[string]string)
func checkOverlappedPath(name, path string) {
// TODO: some paths shouldn't overlap (storage.xxx.path), while some could (data path is the base path for storage path)
if targetName, ok := configuredPaths[path]; ok && targetName != name {
LogStartupProblem(1, log.ERROR, "Configured path %q is used by %q and %q at the same time. The paths must be unique to prevent data loss.", path, targetName, name)
}
configuredPaths[path] = name
}
func PanicInDevOrTesting(msg string, a ...any) {
if !IsProd || IsInTesting {
panic(fmt.Sprintf(msg, a...))
}
log.Error(msg, a...)
}