mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-01 07:10:36 +00:00
13d0f24423
* 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>
427 lines
12 KiB
Go
427 lines
12 KiB
Go
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package user
|
|
|
|
import (
|
|
stdCtx "context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
activities_model "gitea.dev/models/activities"
|
|
"gitea.dev/models/db"
|
|
git_model "gitea.dev/models/git"
|
|
issues_model "gitea.dev/models/issues"
|
|
access_model "gitea.dev/models/perm/access"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unit"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/base"
|
|
"gitea.dev/modules/container"
|
|
"gitea.dev/modules/log"
|
|
"gitea.dev/modules/optional"
|
|
"gitea.dev/modules/setting"
|
|
"gitea.dev/modules/structs"
|
|
"gitea.dev/modules/templates"
|
|
"gitea.dev/modules/util"
|
|
"gitea.dev/services/context"
|
|
issue_service "gitea.dev/services/issue"
|
|
"gitea.dev/services/notifications"
|
|
pull_service "gitea.dev/services/pull"
|
|
)
|
|
|
|
const (
|
|
tplNotification templates.TplName = "user/notification/notification"
|
|
tplNotificationDiv templates.TplName = "user/notification/notification_div"
|
|
tplNotificationSubscriptions templates.TplName = "user/notification/notification_subscriptions"
|
|
)
|
|
|
|
// Notifications is the notification list page
|
|
func Notifications(ctx *context.Context) {
|
|
prepareUserNotificationsData(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
if ctx.FormBool("div-only") {
|
|
ctx.HTML(http.StatusOK, tplNotificationDiv)
|
|
return
|
|
}
|
|
ctx.HTML(http.StatusOK, tplNotification)
|
|
}
|
|
|
|
func prepareUserNotificationsData(ctx *context.Context) {
|
|
pageType := ctx.FormString("type", ctx.FormString("q")) // "q" is the legacy query parameter for "page type"
|
|
page := max(1, ctx.FormInt("page"))
|
|
perPage := util.IfZero(ctx.FormInt("perPage"), 20) // this value is never used or exposed ....
|
|
queryStatus := util.Iif(pageType == "read", activities_model.NotificationStatusRead, activities_model.NotificationStatusUnread)
|
|
|
|
total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
|
|
UserID: ctx.Doer.ID,
|
|
Status: []activities_model.NotificationStatus{queryStatus},
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("ErrGetNotificationCount", err)
|
|
return
|
|
}
|
|
|
|
pager := context.NewPagination(total, perPage, page, 5)
|
|
if pager.Paginater.Current() < page {
|
|
// use the last page if the requested page is more than total pages
|
|
page = pager.Paginater.Current()
|
|
pager = context.NewPagination(total, perPage, page, 5)
|
|
}
|
|
|
|
statuses := []activities_model.NotificationStatus{queryStatus, activities_model.NotificationStatusPinned}
|
|
nls, err := db.Find[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
|
|
ListOptions: db.ListOptions{
|
|
PageSize: perPage,
|
|
Page: page,
|
|
},
|
|
UserID: ctx.Doer.ID,
|
|
Status: statuses,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("db.Find[activities_model.Notification]", err)
|
|
return
|
|
}
|
|
|
|
notifications := activities_model.NotificationList(nls)
|
|
|
|
failCount := 0
|
|
|
|
repos, failures, err := notifications.LoadRepos(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("LoadRepos", err)
|
|
return
|
|
}
|
|
notifications = notifications.Without(failures)
|
|
if err := repos.LoadAttributes(ctx); err != nil {
|
|
ctx.ServerError("LoadAttributes", err)
|
|
return
|
|
}
|
|
failCount += len(failures)
|
|
notifications, failures, err = filterNotificationsByRepoAccess(ctx, ctx.Doer, notifications)
|
|
if err != nil {
|
|
ctx.ServerError("filterNotificationsByRepoAccess", err)
|
|
return
|
|
}
|
|
failCount += len(failures)
|
|
|
|
failures, err = notifications.LoadIssues(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("LoadIssues", err)
|
|
return
|
|
}
|
|
|
|
if err = notifications.LoadIssuePullRequests(ctx); err != nil {
|
|
ctx.ServerError("LoadIssuePullRequests", err)
|
|
return
|
|
}
|
|
|
|
notifications = notifications.Without(failures)
|
|
failCount += len(failures)
|
|
|
|
failures, err = notifications.LoadComments(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("LoadComments", err)
|
|
return
|
|
}
|
|
notifications = notifications.Without(failures)
|
|
failCount += len(failures)
|
|
|
|
if failCount > 0 {
|
|
ctx.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount))
|
|
}
|
|
|
|
ctx.Data["Title"] = ctx.Tr("notifications")
|
|
ctx.Data["PageType"] = pageType
|
|
ctx.Data["Notifications"] = notifications
|
|
ctx.Data["Link"] = setting.AppSubURL + "/notifications"
|
|
ctx.Data["SequenceNumber"] = ctx.FormString("sequence-number")
|
|
|
|
pager.AddParamFromRequest(ctx.Req)
|
|
pager.RemoveParam(container.SetOf("div-only", "sequence-number"))
|
|
ctx.Data["Page"] = pager
|
|
}
|
|
|
|
func filterNotificationsByRepoAccess(ctx stdCtx.Context, doer *user_model.User, notifications activities_model.NotificationList) (activities_model.NotificationList, []int, error) {
|
|
failures := make([]int, 0)
|
|
for i, notification := range notifications {
|
|
if notification.Repository == nil {
|
|
continue
|
|
}
|
|
perm, err := access_model.GetIndividualUserRepoPermission(ctx, notification.Repository, doer)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !perm.HasAnyUnitAccessOrPublicAccess() {
|
|
failures = append(failures, i)
|
|
}
|
|
}
|
|
return notifications.Without(failures), failures, nil
|
|
}
|
|
|
|
// NotificationStatusPost is a route for changing the status of a notification
|
|
func NotificationStatusPost(ctx *context.Context) {
|
|
notificationID := ctx.FormInt64("notification_id")
|
|
var newStatus activities_model.NotificationStatus
|
|
switch ctx.FormString("notification_action") {
|
|
case "mark_as_read":
|
|
newStatus = activities_model.NotificationStatusRead
|
|
case "mark_as_unread":
|
|
newStatus = activities_model.NotificationStatusUnread
|
|
case "pin":
|
|
newStatus = activities_model.NotificationStatusPinned
|
|
default:
|
|
return // ignore user's invalid input
|
|
}
|
|
if _, err := notifications.SetNotificationStatus(ctx, notificationID, ctx.Doer, newStatus); err != nil {
|
|
ctx.ServerError("SetNotificationStatus", err)
|
|
return
|
|
}
|
|
|
|
prepareUserNotificationsData(ctx)
|
|
if ctx.Written() {
|
|
return
|
|
}
|
|
ctx.HTML(http.StatusOK, tplNotificationDiv)
|
|
}
|
|
|
|
// NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read
|
|
func NotificationPurgePost(ctx *context.Context) {
|
|
if err := notifications.MarkAllRead(ctx, ctx.Doer); err != nil {
|
|
ctx.ServerError("MarkAllRead", err)
|
|
return
|
|
}
|
|
|
|
ctx.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther)
|
|
}
|
|
|
|
// NotificationSubscriptions returns the list of subscribed issues
|
|
func NotificationSubscriptions(ctx *context.Context) {
|
|
page := max(ctx.FormInt("page"), 1)
|
|
|
|
sortType := ctx.FormString("sort")
|
|
ctx.Data["SortType"] = sortType
|
|
|
|
state := ctx.FormString("state")
|
|
if !util.SliceContainsString([]string{"all", "open", "closed"}, state, true) {
|
|
state = "all"
|
|
}
|
|
|
|
ctx.Data["State"] = state
|
|
// default state filter is "all"
|
|
showClosed := optional.None[bool]()
|
|
switch state {
|
|
case "closed":
|
|
showClosed = optional.Some(true)
|
|
case "open":
|
|
showClosed = optional.Some(false)
|
|
}
|
|
|
|
issueType := ctx.FormString("issueType")
|
|
// default issue type is no filter
|
|
issueTypeBool := optional.None[bool]()
|
|
switch issueType {
|
|
case "issues":
|
|
issueTypeBool = optional.Some(false)
|
|
case "pulls":
|
|
issueTypeBool = optional.Some(true)
|
|
}
|
|
ctx.Data["IssueType"] = issueType
|
|
|
|
var labelIDs []int64
|
|
selectedLabels := ctx.FormString("labels")
|
|
ctx.Data["Labels"] = selectedLabels
|
|
if len(selectedLabels) > 0 && selectedLabels != "0" {
|
|
var err error
|
|
labelIDs, err = base.StringsToInt64s(strings.Split(selectedLabels, ","))
|
|
if err != nil {
|
|
ctx.Flash.Error(ctx.Tr("invalid_data", selectedLabels), true)
|
|
}
|
|
}
|
|
|
|
count, err := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{
|
|
SubscriberID: ctx.Doer.ID,
|
|
IsClosed: showClosed,
|
|
IsPull: issueTypeBool,
|
|
LabelIDs: labelIDs,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("CountIssues", err)
|
|
return
|
|
}
|
|
issues, err := issues_model.Issues(ctx, &issues_model.IssuesOptions{
|
|
Paginator: &db.ListOptions{
|
|
PageSize: setting.UI.IssuePagingNum,
|
|
Page: page,
|
|
},
|
|
SubscriberID: ctx.Doer.ID,
|
|
SortType: sortType,
|
|
IsClosed: showClosed,
|
|
IsPull: issueTypeBool,
|
|
LabelIDs: labelIDs,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("Issues", err)
|
|
return
|
|
}
|
|
|
|
commitStatuses, lastStatus, err := pull_service.GetIssuesAllCommitStatus(ctx, issues)
|
|
if err != nil {
|
|
ctx.ServerError("GetIssuesAllCommitStatus", err)
|
|
return
|
|
}
|
|
if !ctx.Repo.Permission.CanRead(unit.TypeActions) {
|
|
for key := range commitStatuses {
|
|
git_model.CommitStatusesHideActionsURL(ctx, commitStatuses[key])
|
|
}
|
|
}
|
|
ctx.Data["CommitLastStatus"] = lastStatus
|
|
ctx.Data["CommitStatuses"] = commitStatuses
|
|
ctx.Data["Issues"] = issues
|
|
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, "")
|
|
|
|
approvalCounts, err := issues.GetApprovalCounts(ctx)
|
|
if err != nil {
|
|
ctx.ServerError("ApprovalCounts", err)
|
|
return
|
|
}
|
|
ctx.Data["ApprovalCounts"] = func(issueID int64, typ string) int64 {
|
|
counts, ok := approvalCounts[issueID]
|
|
if !ok || len(counts) == 0 {
|
|
return 0
|
|
}
|
|
reviewTyp := issues_model.ReviewTypeApprove
|
|
switch typ {
|
|
case "reject":
|
|
reviewTyp = issues_model.ReviewTypeReject
|
|
case "waiting":
|
|
reviewTyp = issues_model.ReviewTypeRequest
|
|
}
|
|
for _, count := range counts {
|
|
if count.Type == reviewTyp {
|
|
return count.Count
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
ctx.Data["Status"] = 1
|
|
ctx.Data["Title"] = ctx.Tr("notification.subscriptions")
|
|
|
|
// redirect to last page if request page is more than total pages
|
|
pager := context.NewPagination(count, setting.UI.IssuePagingNum, page, 5)
|
|
if pager.Paginater.Current() < page {
|
|
ctx.Redirect(fmt.Sprintf("/notifications/subscriptions?page=%d", pager.Paginater.Current()))
|
|
return
|
|
}
|
|
pager.AddParamFromRequest(ctx.Req)
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.HTML(http.StatusOK, tplNotificationSubscriptions)
|
|
}
|
|
|
|
// NotificationWatching returns the list of watching repos
|
|
func NotificationWatching(ctx *context.Context) {
|
|
page := max(ctx.FormInt("page"), 1)
|
|
|
|
keyword := ctx.FormTrim("q")
|
|
ctx.Data["Keyword"] = keyword
|
|
|
|
var orderBy db.SearchOrderBy
|
|
ctx.Data["SortType"] = ctx.FormString("sort")
|
|
switch ctx.FormString("sort") {
|
|
case "newest":
|
|
orderBy = db.SearchOrderByNewest
|
|
case "oldest":
|
|
orderBy = db.SearchOrderByOldest
|
|
case "recentupdate":
|
|
orderBy = db.SearchOrderByRecentUpdated
|
|
case "leastupdate":
|
|
orderBy = db.SearchOrderByLeastUpdated
|
|
case "reversealphabetically":
|
|
orderBy = db.SearchOrderByAlphabeticallyReverse
|
|
case "alphabetically":
|
|
orderBy = db.SearchOrderByAlphabetically
|
|
case "moststars":
|
|
orderBy = db.SearchOrderByStarsReverse
|
|
case "feweststars":
|
|
orderBy = db.SearchOrderByStars
|
|
case "mostforks":
|
|
orderBy = db.SearchOrderByForksReverse
|
|
case "fewestforks":
|
|
orderBy = db.SearchOrderByForks
|
|
default:
|
|
ctx.Data["SortType"] = "recentupdate"
|
|
orderBy = db.SearchOrderByRecentUpdated
|
|
}
|
|
|
|
archived := ctx.FormOptionalBool("archived")
|
|
ctx.Data["IsArchived"] = archived
|
|
|
|
fork := ctx.FormOptionalBool("fork")
|
|
ctx.Data["IsFork"] = fork
|
|
|
|
mirror := ctx.FormOptionalBool("mirror")
|
|
ctx.Data["IsMirror"] = mirror
|
|
|
|
template := ctx.FormOptionalBool("template")
|
|
ctx.Data["IsTemplate"] = template
|
|
|
|
private := ctx.FormOptionalBool("private")
|
|
ctx.Data["IsPrivate"] = private
|
|
|
|
repos, count, err := repo_model.SearchRepository(ctx, repo_model.SearchRepoOptions{
|
|
ListOptions: db.ListOptions{
|
|
PageSize: setting.UI.User.RepoPagingNum,
|
|
Page: page,
|
|
},
|
|
Actor: ctx.Doer,
|
|
Keyword: keyword,
|
|
OrderBy: orderBy,
|
|
Private: ctx.IsSigned,
|
|
WatchedByID: ctx.Doer.ID,
|
|
Collaborate: optional.Some(false),
|
|
TopicOnly: ctx.FormBool("topic"),
|
|
IncludeDescription: setting.UI.SearchRepoDescription,
|
|
Archived: archived,
|
|
Fork: fork,
|
|
Mirror: mirror,
|
|
Template: template,
|
|
IsPrivate: private,
|
|
})
|
|
if err != nil {
|
|
ctx.ServerError("SearchRepository", err)
|
|
return
|
|
}
|
|
ctx.Data["Total"] = count
|
|
ctx.Data["Repos"] = repos
|
|
|
|
// redirect to last page if request page is more than total pages
|
|
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
|
|
pager.AddParamFromRequest(ctx.Req)
|
|
ctx.Data["Page"] = pager
|
|
|
|
ctx.Data["Status"] = 2
|
|
ctx.Data["Title"] = ctx.Tr("notification.watching")
|
|
|
|
ctx.HTML(http.StatusOK, tplNotificationSubscriptions)
|
|
}
|
|
|
|
// NewAvailable returns the notification counts
|
|
func NewAvailable(ctx *context.Context) {
|
|
total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{
|
|
UserID: ctx.Doer.ID,
|
|
Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread},
|
|
})
|
|
if err != nil {
|
|
log.Error("db.Count[activities_model.Notification]", err)
|
|
ctx.JSON(http.StatusOK, structs.NotificationCount{New: 0})
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusOK, structs.NotificationCount{New: total})
|
|
}
|