gitea/models/conversations/conversation_update.go
2024-10-30 23:49:24 +08:00

431 lines
13 KiB
Go

// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package conversations
import (
"context"
"fmt"
"strings"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/models/perm"
access_model "code.gitea.io/gitea/models/perm/access"
repo_model "code.gitea.io/gitea/models/repo"
system_model "code.gitea.io/gitea/models/system"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/references"
api "code.gitea.io/gitea/modules/structs"
"xorm.io/builder"
)
// UpdateConversationCols updates cols of conversation
func UpdateConversationCols(ctx context.Context, conversation *Conversation, cols ...string) error {
if _, err := db.GetEngine(ctx).ID(conversation.ID).Cols(cols...).Update(conversation); err != nil {
return err
}
return nil
}
// UpdateConversationAttachments update attachments by UUIDs for the conversation
func UpdateConversationAttachments(ctx context.Context, conversationID int64, uuids []string) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := 0; i < len(attachments); i++ {
attachments[i].ConversationID = conversationID
if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil {
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
}
}
return committer.Commit()
}
// NewConversationOptions represents the options of a new conversation.
type NewConversationOptions struct {
Repo *repo_model.Repository
Conversation *Conversation
LabelIDs []int64
Attachments []string // In UUID format.
IsPull bool
}
// UpdateConversationMentions updates conversation-user relations for mentioned users.
func UpdateConversationMentions(ctx context.Context, conversationID int64, mentions []*user_model.User) error {
if len(mentions) == 0 {
return nil
}
ids := make([]int64, len(mentions))
for i, u := range mentions {
ids[i] = u.ID
}
if err := UpdateConversationUsersByMentions(ctx, conversationID, ids); err != nil {
return fmt.Errorf("UpdateConversationUsersByMentions: %w", err)
}
return nil
}
// FindAndUpdateConversationMentions finds users mentioned in the given content string, and saves them in the database.
func FindAndUpdateConversationMentions(ctx context.Context, conversation *Conversation, doer *user_model.User, content string) (mentions []*user_model.User, err error) {
rawMentions := references.FindAllMentionsMarkdown(content)
mentions, err = ResolveConversationMentionsByVisibility(ctx, conversation, doer, rawMentions)
if err != nil {
return nil, fmt.Errorf("UpdateConversationMentions [%d]: %w", conversation.ID, err)
}
notBlocked := make([]*user_model.User, 0, len(mentions))
for _, user := range mentions {
if !user_model.IsUserBlockedBy(ctx, doer, user.ID) {
notBlocked = append(notBlocked, user)
}
}
mentions = notBlocked
if err = UpdateConversationMentions(ctx, conversation.ID, mentions); err != nil {
return nil, fmt.Errorf("UpdateConversationMentions [%d]: %w", conversation.ID, err)
}
return mentions, err
}
// ResolveConversationMentionsByVisibility returns the users mentioned in an conversation, removing those that
// don't have access to reading it. Teams are expanded into their users, but organizations are ignored.
func ResolveConversationMentionsByVisibility(ctx context.Context, conversation *Conversation, doer *user_model.User, mentions []string) (users []*user_model.User, err error) {
if len(mentions) == 0 {
return nil, nil
}
if err = conversation.LoadRepo(ctx); err != nil {
return nil, err
}
resolved := make(map[string]bool, 10)
var mentionTeams []string
if err := conversation.Repo.LoadOwner(ctx); err != nil {
return nil, err
}
repoOwnerIsOrg := conversation.Repo.Owner.IsOrganization()
if repoOwnerIsOrg {
mentionTeams = make([]string, 0, 5)
}
resolved[doer.LowerName] = true
for _, name := range mentions {
name := strings.ToLower(name)
if _, ok := resolved[name]; ok {
continue
}
if repoOwnerIsOrg && strings.Contains(name, "/") {
names := strings.Split(name, "/")
if len(names) < 2 || names[0] != conversation.Repo.Owner.LowerName {
continue
}
mentionTeams = append(mentionTeams, names[1])
resolved[name] = true
} else {
resolved[name] = false
}
}
if conversation.Repo.Owner.IsOrganization() && len(mentionTeams) > 0 {
teams := make([]*organization.Team, 0, len(mentionTeams))
if err := db.GetEngine(ctx).
Join("INNER", "team_repo", "team_repo.team_id = team.id").
Where("team_repo.repo_id=?", conversation.Repo.ID).
In("team.lower_name", mentionTeams).
Find(&teams); err != nil {
return nil, fmt.Errorf("find mentioned teams: %w", err)
}
if len(teams) != 0 {
checked := make([]int64, 0, len(teams))
unittype := unit.TypeConversations
for _, team := range teams {
if team.AccessMode >= perm.AccessModeAdmin {
checked = append(checked, team.ID)
resolved[conversation.Repo.Owner.LowerName+"/"+team.LowerName] = true
continue
}
has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: conversation.Repo.Owner.ID, TeamID: team.ID, Type: unittype})
if err != nil {
return nil, fmt.Errorf("get team units (%d): %w", team.ID, err)
}
if has {
checked = append(checked, team.ID)
resolved[conversation.Repo.Owner.LowerName+"/"+team.LowerName] = true
}
}
if len(checked) != 0 {
teamusers := make([]*user_model.User, 0, 20)
if err := db.GetEngine(ctx).
Join("INNER", "team_user", "team_user.uid = `user`.id").
In("`team_user`.team_id", checked).
And("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
Find(&teamusers); err != nil {
return nil, fmt.Errorf("get teams users: %w", err)
}
if len(teamusers) > 0 {
users = make([]*user_model.User, 0, len(teamusers))
for _, user := range teamusers {
if already, ok := resolved[user.LowerName]; !ok || !already {
users = append(users, user)
resolved[user.LowerName] = true
}
}
}
}
}
}
// Remove names already in the list to avoid querying the database if pending names remain
mentionUsers := make([]string, 0, len(resolved))
for name, already := range resolved {
if !already {
mentionUsers = append(mentionUsers, name)
}
}
if len(mentionUsers) == 0 {
return users, err
}
if users == nil {
users = make([]*user_model.User, 0, len(mentionUsers))
}
unchecked := make([]*user_model.User, 0, len(mentionUsers))
if err := db.GetEngine(ctx).
Where("`user`.is_active = ?", true).
And("`user`.prohibit_login = ?", false).
In("`user`.lower_name", mentionUsers).
Find(&unchecked); err != nil {
return nil, fmt.Errorf("find mentioned users: %w", err)
}
for _, user := range unchecked {
if already := resolved[user.LowerName]; already || user.IsOrganization() {
continue
}
// Normal users must have read access to the referencing conversation
perm, err := access_model.GetUserRepoPermission(ctx, conversation.Repo, user)
if err != nil {
return nil, fmt.Errorf("GetUserRepoPermission [%d]: %w", user.ID, err)
}
if !perm.CanReadConversations() {
continue
}
users = append(users, user)
}
return users, err
}
// UpdateConversationsMigrationsByType updates all migrated repositories' conversations from gitServiceType to replace originalAuthorID to posterID
func UpdateConversationsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error {
_, err := db.GetEngine(ctx).Table("conversation").
Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType).
And("original_author_id = ?", originalAuthorID).
Update(map[string]any{
"poster_id": posterID,
"original_author": "",
"original_author_id": 0,
})
return err
}
// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID
func UpdateReactionsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error {
_, err := db.GetEngine(ctx).Table("reaction").
Where("original_author_id = ?", originalAuthorID).
And(migratedConversationCond(gitServiceType)).
Update(map[string]any{
"user_id": userID,
"original_author": "",
"original_author_id": 0,
})
return err
}
// DeleteConversationsByRepoID deletes conversations by repositories id
func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentPaths []string, err error) {
// MariaDB has a performance bug: https://jira.mariadb.org/browse/MDEV-16289
// so here it uses "DELETE ... WHERE IN" with pre-queried IDs.
sess := db.GetEngine(ctx)
for {
conversationIDs := make([]int64, 0, db.DefaultMaxInSize)
err := sess.Table(&Conversation{}).Where("repo_id = ?", repoID).OrderBy("id").Limit(db.DefaultMaxInSize).Cols("id").Find(&conversationIDs)
if err != nil {
return nil, err
}
if len(conversationIDs) == 0 {
break
}
// Delete content histories
_, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationContentHistory{})
if err != nil {
return nil, err
}
// Delete comments and attachments
_, err = sess.In("conversation_id", conversationIDs).Delete(&Comment{})
if err != nil {
return nil, err
}
_, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationUser{})
if err != nil {
return nil, err
}
_, err = sess.In("conversation_id", conversationIDs).Delete(&CommentReaction{})
if err != nil {
return nil, err
}
_, err = sess.In("dependent_conversation_id", conversationIDs).Delete(&Comment{})
if err != nil {
return nil, err
}
var attachments []*repo_model.Attachment
err = sess.In("conversation_id", conversationIDs).Find(&attachments)
if err != nil {
return nil, err
}
for j := range attachments {
attachmentPaths = append(attachmentPaths, attachments[j].RelativePath())
}
_, err = sess.In("conversation_id", conversationIDs).Delete(&repo_model.Attachment{})
if err != nil {
return nil, err
}
_, err = sess.In("id", conversationIDs).Delete(&Conversation{})
if err != nil {
return nil, err
}
}
return attachmentPaths, err
}
// DeleteOrphanedConversations delete conversations without a repo
func DeleteOrphanedConversations(ctx context.Context) error {
var attachmentPaths []string
err := db.WithTx(ctx, func(ctx context.Context) error {
var ids []int64
if err := db.GetEngine(ctx).Table("conversation").Distinct("conversation.repo_id").
Join("LEFT", "repository", "conversation.repo_id=repository.id").
Where(builder.IsNull{"repository.id"}).GroupBy("conversation.repo_id").
Find(&ids); err != nil {
return err
}
for i := range ids {
paths, err := DeleteConversationsByRepoID(ctx, ids[i])
if err != nil {
return err
}
attachmentPaths = append(attachmentPaths, paths...)
}
return nil
})
if err != nil {
return err
}
// Remove conversation attachment files.
for i := range attachmentPaths {
system_model.RemoveAllWithNotice(ctx, "Delete conversation attachment", attachmentPaths[i])
}
return nil
}
// NewConversationWithIndex creates conversation with given index
func NewConversationWithIndex(ctx context.Context, opts NewConversationOptions) (err error) {
e := db.GetEngine(ctx)
if opts.Conversation.Index <= 0 {
return fmt.Errorf("no conversation index provided")
}
if opts.Conversation.ID > 0 {
return fmt.Errorf("conversation exist")
}
if _, err := e.Insert(opts.Conversation); err != nil {
return err
}
if err := repo_model.UpdateRepoConversationNumbers(ctx, opts.Conversation.RepoID, false); err != nil {
return err
}
if err = NewConversationUsers(ctx, opts.Repo, opts.Conversation); err != nil {
return err
}
if len(opts.Attachments) > 0 {
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err)
}
for i := 0; i < len(attachments); i++ {
attachments[i].ConversationID = opts.Conversation.ID
if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil {
return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err)
}
}
}
return opts.Conversation.LoadAttributes(ctx)
}
// NewConversation creates new conversation with labels for repository.
func NewConversation(ctx context.Context, repo *repo_model.Repository, conversation *Conversation, uuids []string) (err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
idx, err := db.GetNextResourceIndex(ctx, "conversation_index", repo.ID)
if err != nil {
return fmt.Errorf("generate conversation index failed: %w", err)
}
conversation.Index = idx
if err = NewConversationWithIndex(ctx, NewConversationOptions{
Repo: repo,
Conversation: conversation,
Attachments: uuids,
}); err != nil {
if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewConversationInsert(err) {
return err
}
return fmt.Errorf("newConversation: %w", err)
}
if err = committer.Commit(); err != nil {
return fmt.Errorf("Commit: %w", err)
}
return nil
}