Files
gitea/services/project_events/events.go
T

323 lines
11 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package project_events publishes project board mutations as Server-Sent
// Events so other browser tabs viewing the same board can update their DOM
// in near real time.
//
// Each public Publish* helper marshals a typed payload to JSON, wraps it in
// an *eventsource.Event whose Name is "project-board.{project_id}", and
// fans the event out to every currently connected user that has read
// access to the project. All publish helpers are non-blocking: they spawn
// a goroutine so request handlers do not stall on slow consumers.
package project_events
import (
"context"
"strconv"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/eventsource"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/sessiontag"
)
// WithSessionTag re-exports modules/sessiontag.WithSessionTag so existing
// callers of project_events keep working after the context-key helper was
// extracted into its own dependency-free package (shared with
// milestone_events and any future SSE feature).
func WithSessionTag(ctx context.Context, tag string) context.Context {
return sessiontag.WithSessionTag(ctx, tag)
}
// SessionTagFromContext re-exports modules/sessiontag.SessionTagFromContext.
func SessionTagFromContext(ctx context.Context) string {
return sessiontag.SessionTagFromContext(ctx)
}
// Event payload structs ------------------------------------------------------
// CardMoved is emitted when an issue is moved between columns or reordered
// within a column.
type CardMoved struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
FromColumnID int64 `json:"from_column_id"`
ToColumnID int64 `json:"to_column_id"`
Sorting int64 `json:"sorting"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardLinked is emitted when an issue is added to a project's default column.
type CardLinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
ColumnID int64 `json:"column_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// CardUnlinked is emitted when an issue is removed from a project.
type CardUnlinked struct {
ProjectID int64 `json:"project_id"`
IssueID int64 `json:"issue_id"`
SessionTag string `json:"session_tag,omitempty"`
}
// ColumnCreated is emitted when a new column is added to a project.
type ColumnCreated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
IsDefault bool `json:"is_default"`
}
// ColumnUpdated is emitted when a column's title, color, or sorting changes.
type ColumnUpdated struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
Title string `json:"title"`
Color string `json:"color"`
Sorting int64 `json:"sorting"`
}
// ColumnDeleted is emitted when a column is removed from a project.
// Deletion implicitly relocates issues to the default column, so the
// publisher will also emit one CardMoved per affected issue; the frontend
// only needs to drop the column and react to the per-issue moves.
type ColumnDeleted struct {
ProjectID int64 `json:"project_id"`
ColumnID int64 `json:"column_id"`
}
// ColumnSort is one entry in a ColumnReordered batch.
type ColumnSort struct {
ColumnID int64 `json:"column_id"`
Sorting int64 `json:"sorting"`
}
// ColumnReordered is emitted when columns within a project are dragged into
// a new order.
type ColumnReordered struct {
ProjectID int64 `json:"project_id"`
Columns []ColumnSort `json:"columns"`
}
// ProjectUpdated is emitted when project metadata (title, description,
// card type, open/closed state) changes.
type ProjectUpdated struct {
ProjectID int64 `json:"project_id"`
Title string `json:"title"`
Description string `json:"description"`
CardType string `json:"card_type"`
IsClosed bool `json:"is_closed"`
}
// ProjectDeleted is emitted when a project is deleted.
type ProjectDeleted struct {
ProjectID int64 `json:"project_id"`
}
// Broadcast plumbing ---------------------------------------------------------
// broadcastFn is the package-level seam used to send an event to a set of
// uids. Tests swap it out to capture calls without touching the real
// eventsource manager.
var broadcastFn = defaultBroadcast
func defaultBroadcast(uids []int64, event *eventsource.Event) {
mgr := eventsource.GetManager()
for _, uid := range uids {
mgr.SendMessage(uid, event)
}
}
// connectedUIDsLister returns the uid set the broadcast helpers should
// consider as candidate recipients. Tests override it to feed a
// deterministic list.
var connectedUIDsLister = func() []int64 {
return eventsource.GetManager().ConnectedUIDs()
}
// projectLookup loads a project by id. Stubbable in tests so the
// access-filter logic can be exercised without spinning up a database.
var projectLookup = project_model.GetProjectByID
// projectAccessChecker decides whether the user identified by uid is
// allowed to read the given project. Tests stub this to bypass the real
// permission system.
var projectAccessChecker = canReadProject
// connectedUIDsWithProjectAccess returns the subset of currently connected
// uids that the access checker confirms can read projectID.
func connectedUIDsWithProjectAccess(ctx context.Context, projectID int64) []int64 {
uids := connectedUIDsLister()
if len(uids) == 0 {
return nil
}
project, err := projectLookup(ctx, projectID)
if err != nil {
log.Debug("project_events: GetProjectByID(%d) failed: %v", projectID, err)
return nil
}
allowed := make([]int64, 0, len(uids))
for _, uid := range uids {
ok, err := projectAccessChecker(ctx, uid, project)
if err != nil {
log.Debug("project_events: access check uid=%d project=%d: %v", uid, projectID, err)
continue
}
if ok {
allowed = append(allowed, uid)
}
}
return allowed
}
// canReadProject implements the real read-permission check used in
// production: repo projects defer to the repo's TypeProjects unit access;
// user / org projects fall back to user visibility.
func canReadProject(ctx context.Context, uid int64, project *project_model.Project) (bool, error) {
user, err := user_model.GetUserByID(ctx, uid)
if err != nil {
return false, err
}
if project.RepoID > 0 {
var repo *repo_model.Repository
if project.Repo != nil {
repo = project.Repo
} else {
repo, err = repo_model.GetRepositoryByID(ctx, project.RepoID)
if err != nil {
return false, err
}
}
// AccessModeRead == 1; we use the literal because the
// perm_model package's typed constant would force another
// import alias and the meaning is well established here.
ok, err := access_model.HasAccessUnit(ctx, user, repo, unit.TypeProjects, 1)
if err != nil {
return false, err
}
return ok, nil
}
if project.OwnerID > 0 {
owner := project.Owner
if owner == nil {
owner, err = user_model.GetUserByID(ctx, project.OwnerID)
if err != nil {
return false, err
}
}
return user_model.IsUserVisibleToViewer(ctx, owner, user), nil
}
return false, nil
}
// publishEvent is the shared pipeline used by every Publish* helper.
// It marshals the payload, builds the SSE Event, looks up authorized
// recipients, and fans the event out via broadcastFn. The whole thing
// runs inside the calling goroutine; callers should wrap it in `go` so
// request handlers stay responsive.
func publishEvent(ctx context.Context, projectID int64, payload any) {
data, err := json.Marshal(payload)
if err != nil {
log.Error("project_events: marshal payload for project %d: %v", projectID, err)
return
}
event := &eventsource.Event{
Name: eventName(projectID),
Data: data,
}
uids := connectedUIDsWithProjectAccess(ctx, projectID)
if len(uids) == 0 {
return
}
broadcastFn(uids, event)
}
// eventName returns the SSE event name for a given project id.
func eventName(projectID int64) string {
return "project-board." + strconv.FormatInt(projectID, 10)
}
// Publishers -----------------------------------------------------------------
// PublishCardMoved fans out a CardMoved event for the given payload.
func PublishCardMoved(ctx context.Context, payload CardMoved) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardLinked fans out a CardLinked event for the given payload.
func PublishCardLinked(ctx context.Context, payload CardLinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishCardUnlinked fans out a CardUnlinked event for the given payload.
func PublishCardUnlinked(ctx context.Context, payload CardUnlinked) {
if payload.SessionTag == "" {
payload.SessionTag = SessionTagFromContext(ctx)
}
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnCreated fans out a ColumnCreated event for the given payload.
func PublishColumnCreated(ctx context.Context, payload ColumnCreated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnUpdated fans out a ColumnUpdated event for the given payload.
func PublishColumnUpdated(ctx context.Context, payload ColumnUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnDeleted fans out a ColumnDeleted event for the given payload.
func PublishColumnDeleted(ctx context.Context, payload ColumnDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishColumnReordered fans out a ColumnReordered event for the given payload.
func PublishColumnReordered(ctx context.Context, payload ColumnReordered) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectUpdated fans out a ProjectUpdated event for the given payload.
func PublishProjectUpdated(ctx context.Context, payload ProjectUpdated) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// PublishProjectDeleted fans out a ProjectDeleted event for the given payload.
func PublishProjectDeleted(ctx context.Context, payload ProjectDeleted) {
go publishEvent(detach(ctx), payload.ProjectID, payload)
}
// detach returns a context safe for use in the fire-and-forget publish
// goroutine. The request's context carries a request-scoped DB session
// that is returned to the pool once the HTTP handler completes; reusing
// it from the goroutine races with that teardown and makes subsequent
// queries (GetProjectByID, access checks) fail intermittently. The
// session tag is already resolved synchronously before the goroutine
// starts, so the goroutine needs no request-scoped values — only a
// clean, process-lifetime DB context. ShutdownContext is backed by the
// global engine, outlives any single request, and is cancelled on app
// shutdown so we don't leak goroutines past teardown.
func detach(_ context.Context) context.Context {
return graceful.GetManager().ShutdownContext()
}