2024-02-03 11:34:26 +01:00
|
|
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
|
|
|
|
package websocket
|
|
|
|
|
|
|
|
import (
|
2024-02-03 20:30:04 +01:00
|
|
|
"bytes"
|
2024-02-03 11:34:26 +01:00
|
|
|
"context"
|
2024-02-03 20:30:04 +01:00
|
|
|
"fmt"
|
2024-02-03 11:34:26 +01:00
|
|
|
|
|
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
2024-02-11 14:11:51 +01:00
|
|
|
"code.gitea.io/gitea/models/organization"
|
|
|
|
access_model "code.gitea.io/gitea/models/perm/access"
|
2024-02-03 11:34:26 +01:00
|
|
|
repo_model "code.gitea.io/gitea/models/repo"
|
|
|
|
user_model "code.gitea.io/gitea/models/user"
|
2024-02-03 20:30:04 +01:00
|
|
|
"code.gitea.io/gitea/modules/base"
|
2024-02-03 23:24:40 +01:00
|
|
|
web_context "code.gitea.io/gitea/modules/context"
|
2024-02-03 16:41:20 +01:00
|
|
|
"code.gitea.io/gitea/modules/log"
|
2024-02-03 23:24:40 +01:00
|
|
|
"code.gitea.io/gitea/modules/markup"
|
|
|
|
"code.gitea.io/gitea/modules/markup/markdown"
|
|
|
|
"code.gitea.io/gitea/modules/templates"
|
2024-02-03 11:34:26 +01:00
|
|
|
notify_service "code.gitea.io/gitea/services/notify"
|
2024-02-03 14:11:05 +01:00
|
|
|
"github.com/olahol/melody"
|
2024-02-03 11:34:26 +01:00
|
|
|
)
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
type websocketNotifier struct {
|
2024-02-03 11:34:26 +01:00
|
|
|
notify_service.NullNotifier
|
2024-02-03 23:24:40 +01:00
|
|
|
m *melody.Melody
|
|
|
|
rnd *templates.HTMLRender
|
2024-02-03 11:34:26 +01:00
|
|
|
}
|
|
|
|
|
2024-02-03 23:24:40 +01:00
|
|
|
var tplIssueComment base.TplName = "repo/issue/view_content/comment"
|
2024-02-03 11:34:26 +01:00
|
|
|
|
|
|
|
// NewNotifier create a new webhooksNotifier notifier
|
2024-02-03 14:11:05 +01:00
|
|
|
func NewNotifier(m *melody.Melody) notify_service.Notifier {
|
2024-02-11 14:11:51 +01:00
|
|
|
return &websocketNotifier{
|
2024-02-03 23:24:40 +01:00
|
|
|
m: m,
|
|
|
|
rnd: templates.HTMLRenderer(),
|
2024-02-03 14:11:05 +01:00
|
|
|
}
|
2024-02-03 11:34:26 +01:00
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
var (
|
|
|
|
htmxAddElementEnd = "<div hx-swap-oob=\"beforebegin:%s\">%s</div>"
|
|
|
|
// htmxUpdateElement = "<div hx-swap-oob=\"outerHTML:%s\">%s</div>"
|
|
|
|
htmxRemoveElement = "<div hx-swap-oob=\"delete:%s\"></div>"
|
|
|
|
)
|
2024-02-03 20:30:04 +01:00
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
func (n *websocketNotifier) filterSessions(fn func(*melody.Session, *sessionData) bool) []*melody.Session {
|
2024-02-03 23:24:40 +01:00
|
|
|
sessions, err := n.m.Sessions()
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to get sessions: %v", err)
|
|
|
|
return nil
|
|
|
|
}
|
2024-02-03 20:30:04 +01:00
|
|
|
|
2024-02-03 23:24:40 +01:00
|
|
|
_sessions := make([]*melody.Session, 0, len(sessions))
|
|
|
|
for _, s := range sessions {
|
2024-02-11 14:11:51 +01:00
|
|
|
data, err := getSessionData(s)
|
|
|
|
if err != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if fn(s, data) {
|
2024-02-03 23:24:40 +01:00
|
|
|
_sessions = append(_sessions, s)
|
|
|
|
}
|
2024-02-03 20:30:04 +01:00
|
|
|
}
|
|
|
|
|
2024-02-03 23:24:40 +01:00
|
|
|
return _sessions
|
|
|
|
}
|
2024-02-03 14:11:05 +01:00
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
func (n *websocketNotifier) filterIssueSessions(repo *repo_model.Repository, issue *issues_model.Issue) []*melody.Session {
|
|
|
|
return n.filterSessions(func(s *melody.Session, data *sessionData) bool {
|
|
|
|
// if the user is watching the issue, they will get notifications
|
|
|
|
if !data.isOnURL(fmt.Sprintf("/%s/%s/issues/%d", repo.Owner.Name, repo.Name, issue.Index)) {
|
2024-02-03 14:11:05 +01:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
// if the repo is public, the user will get notifications
|
|
|
|
if !repo.IsPrivate {
|
2024-02-03 14:11:05 +01:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
// if the repo is private, the user will get notifications if they have access to the repo
|
2024-02-03 14:11:05 +01:00
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
// TODO: check if the user has access to the repo
|
|
|
|
return data.userID == issue.PosterID
|
2024-02-03 14:11:05 +01:00
|
|
|
})
|
2024-02-11 14:11:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
func (n *websocketNotifier) CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment, mentions []*user_model.User) {
|
|
|
|
sessions := n.filterIssueSessions(repo, issue)
|
2024-02-03 23:24:40 +01:00
|
|
|
|
|
|
|
for _, s := range sessions {
|
|
|
|
var content bytes.Buffer
|
|
|
|
|
|
|
|
webCtx := web_context.GetWebContext(s.Request)
|
2024-02-11 14:11:51 +01:00
|
|
|
webCtx.Repo.Repository = repo
|
2024-02-03 23:24:40 +01:00
|
|
|
|
|
|
|
t, err := webCtx.Render.TemplateLookup(string(tplIssueComment), webCtx.TemplateContext)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to lookup template: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
if err := comment.LoadPoster(ctx); err != nil {
|
|
|
|
log.Error("Failed to load comment poster: %v", err)
|
2024-02-03 23:24:40 +01:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
if comment.Type == issues_model.CommentTypeComment || comment.Type == issues_model.CommentTypeReview {
|
|
|
|
if err := comment.LoadAttachments(ctx); err != nil {
|
|
|
|
log.Error("Failed to load comment attachments: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
|
|
|
|
Links: markup.Links{
|
|
|
|
Base: webCtx.Repo.RepoLink,
|
|
|
|
},
|
|
|
|
Metas: webCtx.Repo.Repository.ComposeMetas(ctx),
|
|
|
|
GitRepo: webCtx.Repo.GitRepo,
|
|
|
|
Ctx: webCtx,
|
|
|
|
}, comment.Content)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to render comment content: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
comment.ShowRole, err = roleDescriptor(ctx, repo, comment.Poster, issue, comment.HasOriginalAuthor())
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to get role descriptor: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2024-02-03 23:24:40 +01:00
|
|
|
ctxData := map[string]any{}
|
|
|
|
ctxData["Repository"] = repo
|
|
|
|
ctxData["Issue"] = issue
|
|
|
|
ctxData["IsSigned"] = true
|
|
|
|
|
|
|
|
data := map[string]any{}
|
|
|
|
data["ctxData"] = ctxData
|
|
|
|
data["Comment"] = comment
|
|
|
|
|
|
|
|
if err := t.Execute(&content, data); err != nil {
|
|
|
|
log.Error("Template: %v", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
msg := fmt.Sprintf(htmxAddElementEnd, ".timeline-item.comment.form", content.String())
|
2024-02-03 23:24:40 +01:00
|
|
|
err = s.Write([]byte(msg))
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to write to session: %v", err)
|
|
|
|
}
|
2024-02-11 14:11:51 +01:00
|
|
|
}
|
|
|
|
}
|
2024-02-03 23:24:40 +01:00
|
|
|
|
2024-02-11 14:11:51 +01:00
|
|
|
func (n *websocketNotifier) DeleteComment(ctx context.Context, doer *user_model.User, c *issues_model.Comment) {
|
|
|
|
sessions := n.filterIssueSessions(c.Issue.Repo, c.Issue)
|
|
|
|
|
|
|
|
for _, s := range sessions {
|
|
|
|
msg := fmt.Sprintf(htmxRemoveElement, fmt.Sprintf("#%s", c.HashTag()))
|
|
|
|
err := s.Write([]byte(msg))
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Failed to write to session: %v", err)
|
|
|
|
}
|
2024-02-03 16:41:20 +01:00
|
|
|
}
|
2024-02-03 11:34:26 +01:00
|
|
|
}
|
2024-02-11 14:11:51 +01:00
|
|
|
|
|
|
|
// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and issue
|
|
|
|
func roleDescriptor(ctx context.Context, repo *repo_model.Repository, poster *user_model.User, issue *issues_model.Issue, hasOriginalAuthor bool) (issues_model.RoleDescriptor, error) {
|
|
|
|
roleDescriptor := issues_model.RoleDescriptor{}
|
|
|
|
|
|
|
|
if hasOriginalAuthor {
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
perm, err := access_model.GetUserRepoPermission(ctx, repo, poster)
|
|
|
|
if err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the poster is the actual poster of the issue, enable Poster role.
|
|
|
|
roleDescriptor.IsPoster = issue.IsPoster(poster.ID)
|
|
|
|
|
|
|
|
// Check if the poster is owner of the repo.
|
|
|
|
if perm.IsOwner() {
|
|
|
|
// If the poster isn't an admin, enable the owner role.
|
|
|
|
if !poster.IsAdmin {
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Otherwise check if poster is the real repo admin.
|
|
|
|
ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster)
|
|
|
|
if err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
}
|
|
|
|
if ok {
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoOwner
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If repo is organization, check Member role
|
|
|
|
if err := repo.LoadOwner(ctx); err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
}
|
|
|
|
if repo.Owner.IsOrganization() {
|
|
|
|
if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
} else if isMember {
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoMember
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the poster is the collaborator of the repo
|
|
|
|
if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
} else if isCollaborator {
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoCollaborator
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
hasMergedPR, err := issues_model.HasMergedPullRequestInRepo(ctx, repo.ID, poster.ID)
|
|
|
|
if err != nil {
|
|
|
|
return roleDescriptor, err
|
|
|
|
} else if hasMergedPR {
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoContributor
|
|
|
|
} else if issue.IsPull {
|
|
|
|
// only display first time contributor in the first opening pull request
|
|
|
|
roleDescriptor.RoleInRepo = issues_model.RoleRepoFirstTimeContributor
|
|
|
|
}
|
|
|
|
|
|
|
|
return roleDescriptor, nil
|
|
|
|
}
|